Merge commit '692b23e9b9891d360882671d8247965b44920b2f' into work/reports-ui
# Conflicts: # src/main/resources/templates/accounts/activate.html # src/main/resources/templates/accounts/new.html # src/main/resources/templates/bootstrap/form.html
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
# Test Evidence: SMTP-gated account creation and activation
|
# Test Evidence: SMTP-gated account creation and activation
|
||||||
|
|
||||||
- **Test type:** Integration
|
- **Test type:** Integration
|
||||||
- **Requirement IDs:** `ACC-008`–`ACC-014`, `ACC-019`, `ACC-020`, `NOT-008`, `SEC-005`, `SEC-007`
|
- **Requirement IDs:** `ACC-008`–`ACC-012`, `ACC-014`, `ACC-019`, `ACC-020`, `NOT-008`, `SEC-002`–`SEC-004`
|
||||||
- **Scenario IDs:** `AC-ACC-001`, `AC-ACC-002`, `AC-ACC-003`, `AC-ACC-007`
|
- **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`
|
- **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
|
## Protected behavior
|
||||||
|
|
||||||
@@ -65,4 +65,4 @@ BUILD SUCCESS
|
|||||||
|
|
||||||
## External-test boundaries
|
## 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.
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
- **Test type:** Integration
|
- **Test type:** Integration
|
||||||
- **Requirement IDs:** `ACC-002, ACC-014, ACC-020–ACC-021, PRJ-017, ATT-007`
|
- **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`
|
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest.exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes`
|
||||||
- **Implementation commit:** `this milestone commit`
|
- **Implementation commit:** `1235204bf1298599264a07943ca1167432556bd2`
|
||||||
|
|
||||||
## Protected behavior
|
## Protected behavior
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
# Test Evidence: Atomic first administrator bootstrap
|
# Test Evidence: Atomic first administrator bootstrap
|
||||||
|
|
||||||
- **Test type:** Integration
|
- **Test type:** Integration
|
||||||
- **Requirement IDs:** `ACC-001–ACC-004, SEC-001–SEC-002, GOV-013`
|
- **Requirement IDs:** `ACC-001–ACC-003, ACC-009, SEC-001`
|
||||||
- **Scenario IDs:** `AC-ACC-001, AC-ACC-002, AC-SEC-001`
|
- **Scenario IDs:** `AC-ACC-001, AC-ACC-002`
|
||||||
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.BootstrapIntegrationTest`
|
- **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
|
## 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
|
## 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
|
## Hand-derived expected result
|
||||||
|
|
||||||
@@ -47,16 +47,19 @@ The public bootstrap behavior did not exist.
|
|||||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
|
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
|
||||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test
|
./mvnw -Dtest=BootstrapIntegrationTest test
|
||||||
```
|
```
|
||||||
|
|
||||||
**Observed result**
|
**Observed result**
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
|
||||||
BUILD SUCCESS
|
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
|
## Affected suite
|
||||||
|
|
||||||
**Command and result**
|
**Command and result**
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
- **Requirement IDs:** `ARC-001–ARC-008, DB-003–DB-012, OPS-003, TST-001–TST-010`
|
- **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`
|
- **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`
|
- **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
|
## Protected behavior
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
- **Test type:** Integration
|
- **Test type:** Integration
|
||||||
- **Requirement IDs:** `INT-001–INT-008, ACC-011, SEC-001`
|
- **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`
|
- **Test class/method:** `com.lab.labtimesheet.feature.integration.service.SmtpIntegrationTest.failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted`
|
||||||
- **Implementation commit:** `this milestone commit`
|
- **Implementation commit:** `bc70db1d0d8eaa68bb8e22db44e38af27b0fa945`
|
||||||
|
|
||||||
## Protected behavior
|
## Protected behavior
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
|||||||
**Observed result**
|
**Observed result**
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SmtpAccountIntegrationTest.java: cannot find symbol class SmtpConfigurationService
|
The pre-refactor RED test source, then named SmtpAccountIntegrationTest.java, reported missing
|
||||||
SmtpAccountIntegrationTest.java: cannot find symbol class SmtpProbe
|
SmtpConfigurationService and SmtpProbe symbols.
|
||||||
17 compilation errors
|
17 compilation errors
|
||||||
BUILD FAILURE
|
BUILD FAILURE
|
||||||
```
|
```
|
||||||
@@ -48,7 +48,7 @@ The SMTP revision and controllable delivery boundaries were absent.
|
|||||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
|
||||||
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
|
export PATH="$JAVA_HOME/bin:/opt/homebrew/opt/node@24/bin:$PATH"
|
||||||
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
||||||
./mvnw -Dtest=BootstrapIntegrationTest,SmtpAccountIntegrationTest test
|
./mvnw -Dtest=SmtpIntegrationTest test
|
||||||
```
|
```
|
||||||
|
|
||||||
**Observed result**
|
**Observed result**
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
- **Test type:** Unit
|
- **Test type:** Unit
|
||||||
- **Requirement IDs:** `ARC-001–ARC-008`
|
- **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`
|
- **Test class/method:** `com.lab.labtimesheet.config.LayerStructureTest.applicationUsesOnlyApprovedPackageByFeatureStructure`
|
||||||
- **Implementation commit:** `this milestone commit`
|
- **Implementation commit:** `1235204bf1298599264a07943ca1167432556bd2`
|
||||||
|
|
||||||
## Protected behavior
|
## Protected behavior
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
- **Test type:** Web
|
- **Test type:** Web
|
||||||
- **Requirement IDs:** `ACC-008–ACC-011, ACC-014, ACC-019, AUTH-001–AUTH-002, SEC-002–SEC-004`
|
- **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`
|
- **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
|
## Protected behavior
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock
|
|||||||
**Observed result**
|
**Observed result**
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
||||||
BUILD SUCCESS
|
BUILD SUCCESS
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -82,4 +82,4 @@ BUILD SUCCESS
|
|||||||
|
|
||||||
## External-test boundaries
|
## 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
- **Requirement IDs:** `I1-UI-03, I1-UI-04`
|
- **Requirement IDs:** `I1-UI-03, I1-UI-04`
|
||||||
- **Scenario IDs:** `I1-UI-04 authentication integration follow-up`
|
- **Scenario IDs:** `I1-UI-04 authentication integration follow-up`
|
||||||
- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AuthenticationWebIntegrationTest.projectLoginPageSupportsFailureNormalizedSuccessAndLogout`
|
- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AuthenticationWebIntegrationTest.projectLoginPageSupportsFailureNormalizedSuccessAndLogout`
|
||||||
- **Implementation commit:** `this milestone commit`
|
- **Implementation commit:** `c4656a88806a92cb59b2e588035a4124854feb92`
|
||||||
|
|
||||||
## Protected behavior
|
## Protected behavior
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Test Evidence: Project-owned login flow
|
# Test Evidence: Project-owned login flow
|
||||||
|
|
||||||
- **Test type:** Web
|
- **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`
|
- **Scenario IDs:** `I1-UI-04 authentication integration follow-up`
|
||||||
- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AuthenticationWebIntegrationTest.projectLoginPageSupportsFailureNormalizedSuccessAndLogout`
|
- **Test class/method:** `com.lab.labtimesheet.feature.account.controller.AuthenticationWebIntegrationTest.projectLoginPageSupportsFailureNormalizedSuccessAndLogout`
|
||||||
- **Implementation commit:** `this milestone commit`
|
- **Implementation commit:** `a18d8e1d3dd02c8978033f09563d2ec9341926c7`
|
||||||
|
|
||||||
## Protected behavior
|
## Protected behavior
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -6,10 +6,16 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
|||||||
|
|
||||||
import com.lab.labtimesheet.config.SecurityProperties;
|
import com.lab.labtimesheet.config.SecurityProperties;
|
||||||
|
|
||||||
|
/** Application entry point and root component-scan boundary for Lab Timesheet. */
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableConfigurationProperties(SecurityProperties.class)
|
@EnableConfigurationProperties(SecurityProperties.class)
|
||||||
public class LabtimesheetApplication {
|
public class LabtimesheetApplication {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the standalone Spring Boot process.
|
||||||
|
*
|
||||||
|
* @param args command-line arguments forwarded to Spring Boot
|
||||||
|
*/
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(LabtimesheetApplication.class, args);
|
SpringApplication.run(LabtimesheetApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ package com.lab.labtimesheet;
|
|||||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||||
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
||||||
|
|
||||||
|
/** Configures the application when deployed as a traditional servlet-container WAR. */
|
||||||
public class ServletInitializer extends SpringBootServletInitializer {
|
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
|
@Override
|
||||||
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
|
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
|
||||||
return application.sources(LabtimesheetApplication.class);
|
return application.sources(LabtimesheetApplication.class);
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import org.springframework.security.crypto.factory.PasswordEncoderFactories;
|
|||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
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)
|
@Configuration(proxyBeanMethods = false)
|
||||||
class SecurityConfiguration {
|
class SecurityConfiguration {
|
||||||
@Bean
|
@Bean
|
||||||
@@ -27,11 +32,14 @@ class SecurityConfiguration {
|
|||||||
throws Exception {
|
throws Exception {
|
||||||
return http
|
return http
|
||||||
.authorizeHttpRequests(authorize -> authorize
|
.authorizeHttpRequests(authorize -> authorize
|
||||||
.requestMatchers("/bootstrap/**", "/activate/**", "/login", "/error", "/actuator/health")
|
.requestMatchers(
|
||||||
|
"/bootstrap/**", "/activate/**", "/login", "/error", "/assets/**",
|
||||||
|
"/actuator/health")
|
||||||
.permitAll()
|
.permitAll()
|
||||||
.requestMatchers("/admin/**").hasRole("ADMIN")
|
.requestMatchers("/admin/**").hasRole("ADMIN")
|
||||||
.anyRequest().authenticated())
|
.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"))
|
.logout(logout -> logout.logoutSuccessUrl("/login?logout"))
|
||||||
.addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class)
|
.addFilterBefore(bootstrapAccessFilter, AuthorizationFilter.class)
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import java.util.Base64;
|
|||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/** Security material used to encrypt integration credentials at rest. */
|
||||||
@ConfigurationProperties("lab.security")
|
@ConfigurationProperties("lab.security")
|
||||||
public class SecurityProperties {
|
public class SecurityProperties {
|
||||||
private String masterKey;
|
private String masterKey;
|
||||||
@@ -16,6 +17,12 @@ public class SecurityProperties {
|
|||||||
this.masterKey = masterKey;
|
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() {
|
public byte[] decodedMasterKey() {
|
||||||
if (masterKey == null || masterKey.isBlank()) {
|
if (masterKey == null || masterKey.isBlank()) {
|
||||||
throw new IllegalStateException("lab.security.master-key is required");
|
throw new IllegalStateException("lab.security.master-key is required");
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package com.lab.labtimesheet.config;
|
package com.lab.labtimesheet.config;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/** Provides the injectable Vietnam-zone clock used for server-authoritative business dates and time. */
|
||||||
@Configuration(proxyBeanMethods = false)
|
@Configuration(proxyBeanMethods = false)
|
||||||
class TimeConfiguration {
|
class TimeConfiguration {
|
||||||
|
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Ho_Chi_Minh");
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
Clock applicationClock() {
|
Clock applicationClock() {
|
||||||
return Clock.systemUTC();
|
return Clock.system(BUSINESS_ZONE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-38
@@ -1,18 +1,24 @@
|
|||||||
package com.lab.labtimesheet.feature.account.controller;
|
package com.lab.labtimesheet.feature.account.controller;
|
||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.time.LocalDate;
|
|
||||||
|
|
||||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
import com.lab.labtimesheet.feature.account.model.dto.ActivationForm;
|
||||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountForm;
|
||||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
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.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.validation.BindingResult;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.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
|
@Controller
|
||||||
class AccountController {
|
class AccountController {
|
||||||
private final AccountService accounts;
|
private final AccountService accounts;
|
||||||
@@ -22,64 +28,81 @@ class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/admin/accounts/new")
|
@GetMapping("/admin/accounts/new")
|
||||||
String newAccount() {
|
String newAccount(Model model) {
|
||||||
|
if (!model.containsAttribute("accountForm")) {
|
||||||
|
model.addAttribute("accountForm", new CreateAccountForm());
|
||||||
|
}
|
||||||
return "accounts/new";
|
return "accounts/new";
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/admin/accounts")
|
@PostMapping("/admin/accounts")
|
||||||
String create(
|
String create(@Valid @ModelAttribute("accountForm") CreateAccountForm form, BindingResult bindingResult,
|
||||||
@RequestParam String email,
|
Principal principal) {
|
||||||
@RequestParam String displayName,
|
if (bindingResult.hasErrors()) {
|
||||||
@RequestParam GlobalRole role,
|
return "accounts/new";
|
||||||
@RequestParam(required = false) String studentCode,
|
}
|
||||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipStart,
|
|
||||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate internshipEnd,
|
|
||||||
Principal principal,
|
|
||||||
Model model) {
|
|
||||||
try {
|
try {
|
||||||
var result = accounts.create(
|
var result = accounts.create(form.toCommand(), accounts.requireActiveAdminId(principal.getName()));
|
||||||
new CreateAccountCommand(
|
|
||||||
email, displayName, role, clean(studentCode), internshipStart, internshipEnd),
|
|
||||||
accounts.requireActiveAdminId(principal.getName()));
|
|
||||||
return result.deliverySucceeded()
|
return result.deliverySucceeded()
|
||||||
? "redirect:/admin/accounts/new?created"
|
? "redirect:/admin/accounts/new?created"
|
||||||
: "redirect:/admin/accounts/new?deliveryFailed";
|
: "redirect:/admin/accounts/new?deliveryFailed";
|
||||||
|
} catch (DataIntegrityViolationException duplicate) {
|
||||||
|
rejectUniquenessViolation(bindingResult, duplicate);
|
||||||
|
return "accounts/new";
|
||||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||||
model.addAttribute("error", exception.getMessage());
|
bindingResult.reject("account.invalid", exception.getMessage());
|
||||||
return "accounts/new";
|
return "accounts/new";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String clean(String value) {
|
|
||||||
return value == null || value.isBlank() ? null : value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/activate")
|
@GetMapping("/activate")
|
||||||
String activationForm(@RequestParam String token, Model model) {
|
String activationForm(@ModelAttribute("activationForm") ActivationForm form, Model model) {
|
||||||
model.addAttribute("token", token);
|
if (form.getToken() == null || form.getToken().isBlank()) {
|
||||||
|
model.addAttribute("error", "This activation link is invalid or no longer usable");
|
||||||
|
}
|
||||||
return "accounts/activate";
|
return "accounts/activate";
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/activate")
|
@PostMapping("/activate")
|
||||||
String activate(
|
String activate(@Valid @ModelAttribute("activationForm") ActivationForm form, BindingResult bindingResult) {
|
||||||
@RequestParam String token,
|
if (bindingResult.hasErrors()) {
|
||||||
@RequestParam String password,
|
form.clearPasswords();
|
||||||
@RequestParam String confirmPassword,
|
|
||||||
Model model) {
|
|
||||||
if (!password.equals(confirmPassword)) {
|
|
||||||
model.addAttribute("token", token);
|
|
||||||
model.addAttribute("error", "Passwords do not match");
|
|
||||||
return "accounts/activate";
|
return "accounts/activate";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (accounts.activate(token, password)) {
|
if (accounts.activate(form.getToken(), form.getPassword())) {
|
||||||
return "redirect:/login?activated";
|
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) {
|
} catch (IllegalArgumentException exception) {
|
||||||
model.addAttribute("error", exception.getMessage());
|
bindingResult.reject("activation.invalid", exception.getMessage());
|
||||||
}
|
}
|
||||||
model.addAttribute("token", token);
|
form.clearPasswords();
|
||||||
return "accounts/activate";
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -3,6 +3,7 @@ package com.lab.labtimesheet.feature.account.controller;
|
|||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
/** Renders the project-owned form-login page used by Spring Security. */
|
||||||
@Controller
|
@Controller
|
||||||
class AuthenticationController {
|
class AuthenticationController {
|
||||||
@GetMapping("/login")
|
@GetMapping("/login")
|
||||||
|
|||||||
+19
-1
@@ -9,13 +9,31 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
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 {
|
public class BootstrapAccessFilter extends OncePerRequestFilter {
|
||||||
private final BootstrapService bootstrap;
|
private final BootstrapService bootstrap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the pre-bootstrap access guard.
|
||||||
|
*
|
||||||
|
* @param bootstrap durable installation-state service
|
||||||
|
*/
|
||||||
public BootstrapAccessFilter(BootstrapService bootstrap) {
|
public BootstrapAccessFilter(BootstrapService bootstrap) {
|
||||||
this.bootstrap = 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
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||||
throws ServletException, IOException {
|
throws ServletException, IOException {
|
||||||
@@ -29,7 +47,7 @@ public class BootstrapAccessFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
private static boolean allowedBeforeBootstrap(String path) {
|
private static boolean allowedBeforeBootstrap(String path) {
|
||||||
return path.equals("/bootstrap") || path.startsWith("/bootstrap/")
|
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");
|
|| path.equals("/error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-7
@@ -1,15 +1,19 @@
|
|||||||
package com.lab.labtimesheet.feature.account.controller;
|
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 com.lab.labtimesheet.feature.account.service.BootstrapService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.validation.BindingResult;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
/** Renders and processes the one-time first-Admin installation form. */
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/bootstrap")
|
@RequestMapping("/bootstrap")
|
||||||
class BootstrapController {
|
class BootstrapController {
|
||||||
@@ -20,21 +24,30 @@ class BootstrapController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
String form() {
|
String form(Model model) {
|
||||||
requireOpen();
|
requireOpen();
|
||||||
|
if (!model.containsAttribute("bootstrapForm")) {
|
||||||
|
model.addAttribute("bootstrapForm", new BootstrapForm());
|
||||||
|
}
|
||||||
return "bootstrap/form";
|
return "bootstrap/form";
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
String create(@RequestParam String email, @RequestParam String displayName, @RequestParam String password,
|
String create(@Valid @ModelAttribute("bootstrapForm") BootstrapForm form, BindingResult bindingResult) {
|
||||||
Model model) {
|
requireOpen();
|
||||||
|
if (bindingResult.hasErrors()) {
|
||||||
|
form.setPassword(null);
|
||||||
|
return "bootstrap/form";
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (bootstrap.bootstrap(email, displayName, password) == BootstrapService.BootstrapOutcome.CREATED) {
|
if (bootstrap.bootstrap(form.getEmail(), form.getDisplayName(), form.getPassword())
|
||||||
return "redirect:/login";
|
== BootstrapService.BootstrapOutcome.CREATED) {
|
||||||
|
return "redirect:/admin/smtp?onboarding";
|
||||||
}
|
}
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||||
} catch (IllegalArgumentException validation) {
|
} catch (IllegalArgumentException validation) {
|
||||||
model.addAttribute("error", validation.getMessage());
|
bindingResult.reject("bootstrap.invalid", validation.getMessage());
|
||||||
|
form.setPassword(null);
|
||||||
return "bootstrap/form";
|
return "bootstrap/form";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.lab.labtimesheet.feature.account.controller;
|
|||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
/** Maps the authenticated application root to the shared role-aware dashboard. */
|
||||||
@Controller
|
@Controller
|
||||||
class HomeController {
|
class HomeController {
|
||||||
@GetMapping("/")
|
@GetMapping("/")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.account.model;
|
package com.lab.labtimesheet.feature.account.model;
|
||||||
|
|
||||||
|
/** Durable authentication lifecycle of a global account. */
|
||||||
public enum AccountStatus {
|
public enum AccountStatus {
|
||||||
PENDING_ACTIVATION,
|
PENDING_ACTIVATION,
|
||||||
ACTIVE,
|
ACTIVE,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.account.model;
|
package com.lab.labtimesheet.feature.account.model;
|
||||||
|
|
||||||
|
/** Immutable system-wide role assigned when an account is created. */
|
||||||
public enum GlobalRole {
|
public enum GlobalRole {
|
||||||
ADMIN,
|
ADMIN,
|
||||||
MENTOR,
|
MENTOR,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.account.model;
|
package com.lab.labtimesheet.feature.account.model;
|
||||||
|
|
||||||
|
/** Durable lifecycle of an Intern's internship independently of account activation. */
|
||||||
public enum InternshipStatus {
|
public enum InternshipStatus {
|
||||||
NOT_STARTED,
|
NOT_STARTED,
|
||||||
ACTIVE,
|
ACTIVE,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.account.model;
|
package com.lab.labtimesheet.feature.account.model;
|
||||||
|
|
||||||
|
/** Purpose discriminator preventing one bearer-token class from serving another workflow. */
|
||||||
public enum TokenPurpose {
|
public enum TokenPurpose {
|
||||||
ACTIVATION,
|
ACTIVATION,
|
||||||
PASSWORD_RESET
|
PASSWORD_RESET
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
package com.lab.labtimesheet.feature.account.model.dto;
|
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) {
|
public record AccountCreation(long userId, boolean deliverySucceeded) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.AccountStatus;
|
||||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
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(
|
public record AccountIdentity(
|
||||||
long id,
|
long id,
|
||||||
String email,
|
String email,
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
package com.lab.labtimesheet.feature.account.model.dto;
|
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) {
|
public record AccountSummary(long activeAccounts, long pendingActivations, long activeInternships) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -4,6 +4,16 @@ import java.time.LocalDate;
|
|||||||
|
|
||||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
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(
|
public record CreateAccountCommand(
|
||||||
String email,
|
String email,
|
||||||
String displayName,
|
String displayName,
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ import jakarta.persistence.ManyToOne;
|
|||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.Version;
|
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
|
@Entity
|
||||||
@Table(name = "app_users")
|
@Table(name = "app_users")
|
||||||
public class AppUser {
|
public class AppUser {
|
||||||
@@ -57,6 +61,7 @@ public class AppUser {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
|
/** Required by JPA; domain instances are created through named factories. */
|
||||||
protected AppUser() {
|
protected AppUser() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,16 +78,42 @@ public class AppUser {
|
|||||||
this.updatedAt = now;
|
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) {
|
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);
|
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(
|
public static AppUser pending(
|
||||||
String email, String displayName, GlobalRole globalRole, AppUser createdBy, Instant now) {
|
String email, String displayName, GlobalRole globalRole, AppUser createdBy, Instant now) {
|
||||||
return new AppUser(
|
return new AppUser(
|
||||||
email, displayName, null, globalRole, AccountStatus.PENDING_ACTIVATION, null, createdBy, now);
|
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) {
|
public void activate(String encodedPassword, Instant now) {
|
||||||
if (accountStatus != AccountStatus.PENDING_ACTIVATION) {
|
if (accountStatus != AccountStatus.PENDING_ACTIVATION) {
|
||||||
throw new IllegalStateException("Only a pending account can activate");
|
throw new IllegalStateException("Only a pending account can activate");
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import jakarta.persistence.Id;
|
|||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.Version;
|
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
|
@Entity
|
||||||
@Table(name = "intern_profiles")
|
@Table(name = "intern_profiles")
|
||||||
public class InternProfile {
|
public class InternProfile {
|
||||||
@@ -56,6 +60,7 @@ public class InternProfile {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
|
/** Required by JPA; domain instances are created through {@link #notStarted}. */
|
||||||
protected InternProfile() {
|
protected InternProfile() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +75,27 @@ public class InternProfile {
|
|||||||
this.updatedAt = now;
|
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(
|
public static InternProfile notStarted(
|
||||||
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
||||||
return new InternProfile(userId, studentCode, internshipStartDate, internshipEndDate, 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) {
|
public void activate(Instant now) {
|
||||||
if (internshipStatus != InternshipStatus.NOT_STARTED) {
|
if (internshipStatus != InternshipStatus.NOT_STARTED) {
|
||||||
throw new IllegalStateException("Only a not-started internship can activate");
|
throw new IllegalStateException("Only a not-started internship can activate");
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import jakarta.persistence.ManyToOne;
|
|||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.Version;
|
import jakarta.persistence.Version;
|
||||||
|
|
||||||
|
/** Durable singleton installation state used to serialize and remember first-Admin bootstrap. */
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "system_state")
|
@Table(name = "system_state")
|
||||||
public class SystemState {
|
public class SystemState {
|
||||||
@@ -37,6 +38,7 @@ public class SystemState {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
|
/** Required by JPA; Flyway creates the singleton row. */
|
||||||
protected SystemState() {
|
protected SystemState() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +46,13 @@ public class SystemState {
|
|||||||
return initialized;
|
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) {
|
public void initialize(AppUser admin, Instant now) {
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
throw new IllegalStateException("Bootstrap is already complete");
|
throw new IllegalStateException("Bootstrap is already complete");
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import jakarta.persistence.GenerationType;
|
|||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Table;
|
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
|
@Entity
|
||||||
@Table(name = "user_action_tokens")
|
@Table(name = "user_action_tokens")
|
||||||
public class UserActionToken {
|
public class UserActionToken {
|
||||||
@@ -45,6 +49,7 @@ public class UserActionToken {
|
|||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
|
/** Required by JPA; domain instances are created through named factories. */
|
||||||
protected UserActionToken() {
|
protected UserActionToken() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,15 +62,37 @@ public class UserActionToken {
|
|||||||
this.createdAt = now;
|
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(
|
public static UserActionToken activation(
|
||||||
long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
||||||
return new UserActionToken(userId, tokenHash, expiresAt, issuedByUserId, 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) {
|
public boolean isUsableAt(Instant now) {
|
||||||
return usedAt == null && invalidatedAt == null && now.isBefore(expiresAt);
|
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) {
|
public void markUsed(Instant now) {
|
||||||
if (!isUsableAt(now)) {
|
if (!isUsableAt(now)) {
|
||||||
throw new IllegalStateException("Activation token is not usable");
|
throw new IllegalStateException("Activation token is not usable");
|
||||||
@@ -73,6 +100,12 @@ public class UserActionToken {
|
|||||||
usedAt = now;
|
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) {
|
public void invalidate(Instant now) {
|
||||||
if (usedAt != null) {
|
if (usedAt != null) {
|
||||||
throw new IllegalStateException("A used token cannot be invalidated");
|
throw new IllegalStateException("A used token cannot be invalidated");
|
||||||
@@ -94,6 +127,11 @@ public class UserActionToken {
|
|||||||
return purpose;
|
return purpose;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a defensive copy of the persisted token hash.
|
||||||
|
*
|
||||||
|
* @return copied SHA-256 hash bytes
|
||||||
|
*/
|
||||||
public byte[] getTokenHash() {
|
public byte[] getTokenHash() {
|
||||||
return Arrays.copyOf(tokenHash, tokenHash.length);
|
return Arrays.copyOf(tokenHash, tokenHash.length);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,30 @@ import org.springframework.data.jpa.repository.Lock;
|
|||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/** Account-feature persistence boundary for global users. */
|
||||||
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
|
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
|
||||||
|
/**
|
||||||
|
* 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")
|
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
|
||||||
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
|
Optional<AppUser> 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)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select u from AppUser u where u.id = :id")
|
@Query("select u from AppUser u where u.id = :id")
|
||||||
Optional<AppUser> findForUpdateById(@Param("id") Long id);
|
Optional<AppUser> findForUpdateById(@Param("id") Long id);
|
||||||
|
|
||||||
|
/** Counts accounts matching an immutable role and lifecycle state. */
|
||||||
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
|
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
|
||||||
|
|
||||||
|
/** Counts accounts in a lifecycle state. */
|
||||||
long countByAccountStatus(AccountStatus status);
|
long countByAccountStatus(AccountStatus status);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -10,14 +10,24 @@ import org.springframework.data.jpa.repository.Lock;
|
|||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/** Account-feature persistence boundary for Intern lifecycle and eligibility. */
|
||||||
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
|
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
|
||||||
|
/** Returns whether an Intern profile has the requested lifecycle state. */
|
||||||
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
|
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
|
||||||
|
|
||||||
|
/** Returns whether an Intern is in the requested state throughout the supplied inclusive date point. */
|
||||||
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
|
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
|
||||||
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
|
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
|
||||||
|
|
||||||
|
/** Counts Intern profiles in a lifecycle state. */
|
||||||
long countByInternshipStatus(InternshipStatus status);
|
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)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select p from InternProfile p where p.userId = :userId")
|
@Query("select p from InternProfile p where p.userId = :userId")
|
||||||
java.util.Optional<InternProfile> findForUpdateByUserId(@Param("userId") Long userId);
|
java.util.Optional<InternProfile> findForUpdateByUserId(@Param("userId") Long userId);
|
||||||
|
|||||||
+6
@@ -8,7 +8,13 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import org.springframework.data.jpa.repository.Lock;
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
/** Persistence boundary for the single durable installation-state row. */
|
||||||
public interface SystemStateRepository extends JpaRepository<SystemState, Short> {
|
public interface SystemStateRepository extends JpaRepository<SystemState, Short> {
|
||||||
|
/**
|
||||||
|
* Locks the singleton row so concurrent bootstrap attempts cannot both create a first Admin.
|
||||||
|
*
|
||||||
|
* @return locked installation state
|
||||||
|
*/
|
||||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select s from SystemState s where s.singletonId = 1")
|
@Query("select s from SystemState s where s.singletonId = 1")
|
||||||
Optional<SystemState> findSingletonForUpdate();
|
Optional<SystemState> findSingletonForUpdate();
|
||||||
|
|||||||
+14
@@ -10,12 +10,26 @@ import org.springframework.data.jpa.repository.Lock;
|
|||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
/** Persistence boundary for hashed, one-time account-action tokens. */
|
||||||
public interface UserActionTokenRepository extends JpaRepository<UserActionToken, Long> {
|
public interface UserActionTokenRepository extends JpaRepository<UserActionToken, Long> {
|
||||||
|
/**
|
||||||
|
* 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)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select t from UserActionToken t where t.tokenHash = :hash and t.purpose = :purpose")
|
@Query("select t from UserActionToken t where t.tokenHash = :hash and t.purpose = :purpose")
|
||||||
Optional<UserActionToken> findForUpdateByHashAndPurpose(
|
Optional<UserActionToken> findForUpdateByHashAndPurpose(
|
||||||
@Param("hash") byte[] hash, @Param("purpose") TokenPurpose purpose);
|
@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)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
@Query("select t from UserActionToken t where t.id = :id")
|
@Query("select t from UserActionToken t where t.id = :id")
|
||||||
Optional<UserActionToken> findForUpdateById(@Param("id") Long id);
|
Optional<UserActionToken> findForUpdateById(@Param("id") Long id);
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
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
|
@Service
|
||||||
public class AccountService {
|
public class AccountService {
|
||||||
private static final Duration ACTIVATION_LIFETIME = Duration.ofHours(24);
|
private static final Duration ACTIVATION_LIFETIME = Duration.ofHours(24);
|
||||||
@@ -63,6 +67,15 @@ public class AccountService {
|
|||||||
this.publicOrigin = normalizeOrigin(publicOrigin);
|
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) {
|
public AccountCreation create(CreateAccountCommand command, long adminId) {
|
||||||
ValidatedAccount account = validate(command);
|
ValidatedAccount account = validate(command);
|
||||||
if (!mailDelivery.isAvailable()) {
|
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
|
@Transactional
|
||||||
public boolean activate(String rawToken, String password) {
|
public boolean activate(String rawToken, String password) {
|
||||||
BootstrapService.requirePassword(password);
|
BootstrapService.requirePassword(password);
|
||||||
@@ -113,6 +134,14 @@ public class AccountService {
|
|||||||
return true;
|
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
|
@Transactional
|
||||||
public void activateInternship(long internUserId, long adminId) {
|
public void activateInternship(long internUserId, long adminId) {
|
||||||
AppUser admin = users.findById(adminId)
|
AppUser admin = users.findById(adminId)
|
||||||
@@ -124,11 +153,19 @@ public class AccountService {
|
|||||||
if (intern.getGlobalRole() != GlobalRole.INTERN || intern.getAccountStatus() != AccountStatus.ACTIVE) {
|
if (intern.getGlobalRole() != GlobalRole.INTERN || intern.getAccountStatus() != AccountStatus.ACTIVE) {
|
||||||
throw new IllegalArgumentException("An active Intern account is required");
|
throw new IllegalArgumentException("An active Intern account is required");
|
||||||
}
|
}
|
||||||
internProfiles.findForUpdateByUserId(internUserId)
|
InternProfile profile = internProfiles.findForUpdateByUserId(internUserId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Intern profile not found"))
|
.orElseThrow(() -> new IllegalArgumentException("Intern profile not found"));
|
||||||
.activate(clock.instant());
|
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)
|
@Transactional(readOnly = true)
|
||||||
public AccountSummary summary() {
|
public AccountSummary summary() {
|
||||||
return new AccountSummary(
|
return new AccountSummary(
|
||||||
@@ -137,18 +174,38 @@ public class AccountService {
|
|||||||
internProfiles.countByInternshipStatus(InternshipStatus.ACTIVE));
|
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)
|
@Transactional(readOnly = true)
|
||||||
public AccountIdentity requireIdentityById(long userId) {
|
public AccountIdentity requireIdentityById(long userId) {
|
||||||
return users.findById(userId).map(AccountService::identity)
|
return users.findById(userId).map(AccountService::identity)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
|
.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)
|
@Transactional(readOnly = true)
|
||||||
public AccountIdentity requireIdentityByEmail(String email) {
|
public AccountIdentity requireIdentityByEmail(String email) {
|
||||||
return users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)).map(AccountService::identity)
|
return users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)).map(AccountService::identity)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
|
.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)
|
@Transactional(readOnly = true)
|
||||||
public boolean isEligibleIntern(long userId) {
|
public boolean isEligibleIntern(long userId) {
|
||||||
return users.findById(userId)
|
return users.findById(userId)
|
||||||
@@ -159,6 +216,14 @@ public class AccountService {
|
|||||||
.isPresent();
|
.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)
|
@Transactional(readOnly = true)
|
||||||
public boolean isEligibleIntern(long userId, LocalDate workDate) {
|
public boolean isEligibleIntern(long userId, LocalDate workDate) {
|
||||||
if (workDate == null) {
|
if (workDate == null) {
|
||||||
@@ -173,6 +238,13 @@ public class AccountService {
|
|||||||
.isPresent();
|
.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)
|
@Transactional(readOnly = true)
|
||||||
public AccountIdentity requireEligibleIntern(long userId) {
|
public AccountIdentity requireEligibleIntern(long userId) {
|
||||||
if (!isEligibleIntern(userId)) {
|
if (!isEligibleIntern(userId)) {
|
||||||
@@ -181,6 +253,13 @@ public class AccountService {
|
|||||||
return requireIdentityById(userId);
|
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)
|
@Transactional(readOnly = true)
|
||||||
public long requireActiveAdminId(String email) {
|
public long requireActiveAdminId(String email) {
|
||||||
AppUser user = users.findByNormalizedEmail(BootstrapService.normalizeEmail(email))
|
AppUser user = users.findByNormalizedEmail(BootstrapService.normalizeEmail(email))
|
||||||
@@ -188,6 +267,13 @@ public class AccountService {
|
|||||||
return requireActiveAdmin(user);
|
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)
|
@Transactional(readOnly = true)
|
||||||
public long requireActiveAdminId(long userId) {
|
public long requireActiveAdminId(long userId) {
|
||||||
AppUser user = users.findById(userId)
|
AppUser user = users.findById(userId)
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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
|
@Service
|
||||||
public class BootstrapService {
|
public class BootstrapService {
|
||||||
private final SystemStateRepository systemStates;
|
private final SystemStateRepository systemStates;
|
||||||
@@ -26,6 +30,14 @@ public class BootstrapService {
|
|||||||
this.clock = clock;
|
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
|
@Transactional
|
||||||
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
|
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
|
||||||
String normalizedEmail = normalizeEmail(email);
|
String normalizedEmail = normalizeEmail(email);
|
||||||
@@ -44,15 +56,32 @@ public class BootstrapService {
|
|||||||
return BootstrapOutcome.CREATED;
|
return BootstrapOutcome.CREATED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the durable installation state.
|
||||||
|
*
|
||||||
|
* @return {@code true} after the first Admin has been committed
|
||||||
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public boolean isInitialized() {
|
public boolean isInitialized() {
|
||||||
return systemStates.findById((short) 1).map(SystemState::isInitialized).orElse(false);
|
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) {
|
public static String normalizeEmail(String email) {
|
||||||
return requireText(email, "Email").toLowerCase(Locale.ROOT);
|
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) {
|
public static void requirePassword(String password) {
|
||||||
if (password == null || password.length() < 12 || password.length() > 128) {
|
if (password == null || password.length() < 12 || password.length() > 128) {
|
||||||
throw new IllegalArgumentException("Password must contain 12 through 128 characters");
|
throw new IllegalArgumentException("Password must contain 12 through 128 characters");
|
||||||
@@ -66,6 +95,7 @@ public class BootstrapService {
|
|||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of attempting the single allowed installation bootstrap. */
|
||||||
public enum BootstrapOutcome {
|
public enum BootstrapOutcome {
|
||||||
CREATED,
|
CREATED,
|
||||||
ALREADY_INITIALIZED
|
ALREADY_INITIALIZED
|
||||||
|
|||||||
+8
@@ -9,6 +9,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/** Adapts persisted account credentials and lifecycle state to Spring Security authentication. */
|
||||||
@Service
|
@Service
|
||||||
class DatabaseUserDetailsService implements UserDetailsService {
|
class DatabaseUserDetailsService implements UserDetailsService {
|
||||||
private final AppUserRepository users;
|
private final AppUserRepository users;
|
||||||
@@ -17,6 +18,13 @@ class DatabaseUserDetailsService implements UserDetailsService {
|
|||||||
this.users = users;
|
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
|
@Override
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
|
|||||||
+118
-17
@@ -1,20 +1,43 @@
|
|||||||
package com.lab.labtimesheet.feature.integration.controller;
|
package com.lab.labtimesheet.feature.integration.controller;
|
||||||
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
import com.lab.labtimesheet.feature.integration.model.dto.SmtpActionForm;
|
||||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
import com.lab.labtimesheet.feature.integration.model.dto.SmtpForm;
|
||||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
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.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.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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
|
@Controller
|
||||||
@RequestMapping("/admin/smtp")
|
@RequestMapping("/admin/smtp")
|
||||||
class SmtpController {
|
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<String> 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 SmtpConfigurationService smtp;
|
||||||
private final AccountService accounts;
|
private final AccountService accounts;
|
||||||
|
|
||||||
@@ -24,29 +47,107 @@ class SmtpController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
String form() {
|
String form(Model model) {
|
||||||
return "smtp/form";
|
return renderForm(model, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/draft")
|
@PostMapping("/draft")
|
||||||
String saveDraft(@RequestParam String host, @RequestParam int port, @RequestParam SecurityMode securityMode,
|
String saveDraft(@Valid @ModelAttribute("smtpForm") SmtpForm form, BindingResult bindingResult,
|
||||||
@RequestParam(required = false) String username, @RequestParam(required = false) String password,
|
Principal principal, Model model) {
|
||||||
@RequestParam String fromAddress, @RequestParam String fromName, Principal principal) {
|
if (bindingResult.hasErrors()) {
|
||||||
smtp.saveDraft(adminId(principal),
|
form.clearPassword();
|
||||||
new SmtpDraft(host, port, securityMode, username, password, fromAddress, fromName));
|
return renderForm(model, form);
|
||||||
return "redirect:/admin/smtp";
|
}
|
||||||
|
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")
|
@PostMapping("/test")
|
||||||
String test(@RequestParam long draftId, Principal principal) {
|
String test(@Valid @ModelAttribute("smtpAction") SmtpActionForm action, BindingResult bindingResult,
|
||||||
smtp.testDraft(draftId, adminId(principal), principal.getName());
|
Principal principal, Model model) {
|
||||||
return "redirect:/admin/smtp";
|
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")
|
@PostMapping("/activate")
|
||||||
String activate(@RequestParam long draftId, Principal principal) {
|
String activate(@Valid @ModelAttribute("smtpAction") SmtpActionForm action, BindingResult bindingResult,
|
||||||
smtp.activate(draftId, adminId(principal));
|
Principal principal, Model model) {
|
||||||
return "redirect:/admin/smtp";
|
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) {
|
private long adminId(Principal principal) {
|
||||||
|
|||||||
+23
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.integration.model;
|
package com.lab.labtimesheet.feature.integration.model;
|
||||||
|
|
||||||
|
/** Transport security mode used when opening an SMTP connection. */
|
||||||
public enum SecurityMode {
|
public enum SecurityMode {
|
||||||
NONE,
|
NONE,
|
||||||
STARTTLS,
|
STARTTLS,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.lab.labtimesheet.feature.integration.model;
|
package com.lab.labtimesheet.feature.integration.model;
|
||||||
|
|
||||||
|
/** Lifecycle state of a versioned SMTP configuration. */
|
||||||
public enum SmtpStatus {
|
public enum SmtpStatus {
|
||||||
DRAFT,
|
DRAFT,
|
||||||
ACTIVE,
|
ACTIVE,
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
package com.lab.labtimesheet.feature.integration.model.dto;
|
package com.lab.labtimesheet.feature.integration.model.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-GCM output persisted for an integration credential; arrays are defensively copied at every boundary.
|
||||||
|
*
|
||||||
|
* @param ciphertext encrypted credential including the authentication tag
|
||||||
|
* @param nonce unique 96-bit nonce used for this encryption
|
||||||
|
* @param keyVersion key-rotation identifier
|
||||||
|
*/
|
||||||
public record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) {
|
public record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) {
|
||||||
|
/** Defensively copies both byte arrays before this value can cross the encryption boundary. */
|
||||||
public EncryptedSecret {
|
public EncryptedSecret {
|
||||||
ciphertext = ciphertext.clone();
|
ciphertext = ciphertext.clone();
|
||||||
nonce = nonce.clone();
|
nonce = nonce.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return a defensive copy of the encrypted credential bytes */
|
||||||
@Override
|
@Override
|
||||||
public byte[] ciphertext() {
|
public byte[] ciphertext() {
|
||||||
return ciphertext.clone();
|
return ciphertext.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return a defensive copy of the AES-GCM nonce */
|
||||||
@Override
|
@Override
|
||||||
public byte[] nonce() {
|
public byte[] nonce() {
|
||||||
return nonce.clone();
|
return nonce.clone();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,18 @@ package com.lab.labtimesheet.feature.integration.model.dto;
|
|||||||
|
|
||||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
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,
|
public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password,
|
||||||
String fromAddress, String fromName) {
|
String fromAddress, String fromName) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ package com.lab.labtimesheet.feature.integration.model.dto;
|
|||||||
|
|
||||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
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,
|
public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password,
|
||||||
String fromAddress, String fromName) {
|
String fromAddress, String fromName) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
+45
@@ -16,6 +16,10 @@ import jakarta.persistence.Id;
|
|||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import jakarta.persistence.Version;
|
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
|
@Entity
|
||||||
@Table(name = "smtp_configurations")
|
@Table(name = "smtp_configurations")
|
||||||
public class SmtpConfiguration {
|
public class SmtpConfiguration {
|
||||||
@@ -85,9 +89,19 @@ public class SmtpConfiguration {
|
|||||||
@Version
|
@Version
|
||||||
private long version;
|
private long version;
|
||||||
|
|
||||||
|
/** Required by JPA; revisions are created through {@link #draft}. */
|
||||||
protected SmtpConfiguration() {
|
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) {
|
public static SmtpConfiguration draft(SmtpDraft draft, EncryptedSecret password, long adminId, Instant now) {
|
||||||
SmtpConfiguration configuration = new SmtpConfiguration();
|
SmtpConfiguration configuration = new SmtpConfiguration();
|
||||||
configuration.status = SmtpStatus.DRAFT;
|
configuration.status = SmtpStatus.DRAFT;
|
||||||
@@ -97,6 +111,14 @@ public class SmtpConfiguration {
|
|||||||
return configuration;
|
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) {
|
public void updateDraft(SmtpDraft draft, EncryptedSecret password, Instant now) {
|
||||||
if (status != SmtpStatus.DRAFT) {
|
if (status != SmtpStatus.DRAFT) {
|
||||||
throw new IllegalStateException("Only an SMTP draft can be edited");
|
throw new IllegalStateException("Only an SMTP draft can be edited");
|
||||||
@@ -115,6 +137,13 @@ public class SmtpConfiguration {
|
|||||||
updatedAt = now;
|
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) {
|
public void markTested(long adminId, Instant now) {
|
||||||
if (status != SmtpStatus.DRAFT) {
|
if (status != SmtpStatus.DRAFT) {
|
||||||
throw new IllegalStateException("SMTP draft is no longer available");
|
throw new IllegalStateException("SMTP draft is no longer available");
|
||||||
@@ -124,6 +153,13 @@ public class SmtpConfiguration {
|
|||||||
updatedAt = now;
|
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) {
|
public void activate(long adminId, Instant now) {
|
||||||
if (status != SmtpStatus.DRAFT || testedAt == null) {
|
if (status != SmtpStatus.DRAFT || testedAt == null) {
|
||||||
throw new IllegalStateException("SMTP draft must pass a test before activation");
|
throw new IllegalStateException("SMTP draft must pass a test before activation");
|
||||||
@@ -134,6 +170,13 @@ public class SmtpConfiguration {
|
|||||||
updatedAt = now;
|
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) {
|
public void retire(long adminId, Instant now) {
|
||||||
if (status != SmtpStatus.ACTIVE) {
|
if (status != SmtpStatus.ACTIVE) {
|
||||||
throw new IllegalStateException("Only active SMTP can be retired");
|
throw new IllegalStateException("Only active SMTP can be retired");
|
||||||
@@ -172,10 +215,12 @@ public class SmtpConfiguration {
|
|||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return a defensive copy of encrypted password bytes, or {@code null} */
|
||||||
public byte[] getPasswordCiphertext() {
|
public byte[] getPasswordCiphertext() {
|
||||||
return passwordCiphertext == null ? null : passwordCiphertext.clone();
|
return passwordCiphertext == null ? null : passwordCiphertext.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return a defensive copy of the AES-GCM nonce, or {@code null} */
|
||||||
public byte[] getPasswordNonce() {
|
public byte[] getPasswordNonce() {
|
||||||
return passwordNonce == null ? null : passwordNonce.clone();
|
return passwordNonce == null ? null : passwordNonce.clone();
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -8,11 +8,21 @@ import jakarta.persistence.LockModeType;
|
|||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Lock;
|
import org.springframework.data.jpa.repository.Lock;
|
||||||
|
|
||||||
|
/** Integration-feature persistence boundary for retained SMTP revisions. */
|
||||||
public interface SmtpConfigurationRepository extends JpaRepository<SmtpConfiguration, Long> {
|
public interface SmtpConfigurationRepository extends JpaRepository<SmtpConfiguration, Long> {
|
||||||
|
/** Finds the single revision in a given lifecycle state. */
|
||||||
Optional<SmtpConfiguration> findByStatus(SmtpStatus status);
|
Optional<SmtpConfiguration> findByStatus(SmtpStatus status);
|
||||||
|
|
||||||
|
/** Returns whether a revision exists in a lifecycle state. */
|
||||||
boolean existsByStatus(SmtpStatus status);
|
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)
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
Optional<SmtpConfiguration> findWithLockByIdAndStatus(Long id, SmtpStatus status);
|
Optional<SmtpConfiguration> findWithLockByIdAndStatus(Long id, SmtpStatus status);
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-7
@@ -1,18 +1,40 @@
|
|||||||
package com.lab.labtimesheet.feature.integration.service;
|
package com.lab.labtimesheet.feature.integration.service;
|
||||||
|
|
||||||
|
import java.io.UnsupportedEncodingException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Properties;
|
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.dto.SmtpConnection;
|
||||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
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.JavaMailSenderImpl;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
import org.springframework.stereotype.Component;
|
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
|
@Component
|
||||||
class JavaMailSmtpProbe implements SmtpProbe {
|
class JavaMailSmtpProbe implements SmtpProbe {
|
||||||
|
private static final String TIMEOUT_MILLIS = "5000";
|
||||||
|
|
||||||
|
private final Supplier<JavaMailSenderImpl> senderFactory;
|
||||||
|
|
||||||
|
JavaMailSmtpProbe() {
|
||||||
|
this(JavaMailSenderImpl::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
JavaMailSmtpProbe(Supplier<JavaMailSenderImpl> senderFactory) {
|
||||||
|
this.senderFactory = senderFactory;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||||
JavaMailSenderImpl sender = new JavaMailSenderImpl();
|
JavaMailSenderImpl sender = senderFactory.get();
|
||||||
sender.setHost(connection.host());
|
sender.setHost(connection.host());
|
||||||
sender.setPort(connection.port());
|
sender.setPort(connection.port());
|
||||||
sender.setUsername(connection.username());
|
sender.setUsername(connection.username());
|
||||||
@@ -24,11 +46,21 @@ class JavaMailSmtpProbe implements SmtpProbe {
|
|||||||
} else if (connection.securityMode() == SecurityMode.TLS) {
|
} else if (connection.securityMode() == SecurityMode.TLS) {
|
||||||
sender.setProtocol("smtps");
|
sender.setProtocol("smtps");
|
||||||
}
|
}
|
||||||
SimpleMailMessage message = new SimpleMailMessage();
|
String propertyPrefix = connection.securityMode() == SecurityMode.TLS ? "mail.smtps" : "mail.smtp";
|
||||||
message.setFrom(connection.fromAddress());
|
properties.setProperty(propertyPrefix + ".connectiontimeout", TIMEOUT_MILLIS);
|
||||||
message.setTo(recipient);
|
properties.setProperty(propertyPrefix + ".timeout", TIMEOUT_MILLIS);
|
||||||
message.setSubject(subject);
|
properties.setProperty(propertyPrefix + ".writetimeout", TIMEOUT_MILLIS);
|
||||||
message.setText(body);
|
|
||||||
|
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);
|
sender.send(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -7,6 +7,10 @@ import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepo
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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
|
@Service
|
||||||
public class MailDeliveryService {
|
public class MailDeliveryService {
|
||||||
private final SmtpConfigurationRepository configurations;
|
private final SmtpConfigurationRepository configurations;
|
||||||
@@ -19,15 +23,34 @@ public class MailDeliveryService {
|
|||||||
this.probe = probe;
|
this.probe = probe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports whether workflows may emit required email.
|
||||||
|
*
|
||||||
|
* @return {@code true} when an active tested SMTP revision exists
|
||||||
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public boolean isAvailable() {
|
public boolean isAvailable() {
|
||||||
return configurations.existsByStatus(SmtpStatus.ACTIVE);
|
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) {
|
public void send(String recipient, String subject, String body) {
|
||||||
probe.send(activeConnection(), recipient, subject, 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)
|
@Transactional(readOnly = true)
|
||||||
public SmtpConnection activeConnection() {
|
public SmtpConnection activeConnection() {
|
||||||
return configurations.findByStatus(SmtpStatus.ACTIVE)
|
return configurations.findByStatus(SmtpStatus.ACTIVE)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import javax.crypto.spec.GCMParameterSpec;
|
|||||||
import javax.crypto.spec.SecretKeySpec;
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** Encrypts SMTP credentials with AES-256-GCM using a fresh nonce per stored revision. */
|
||||||
@Component
|
@Component
|
||||||
public class SecretCipher {
|
public class SecretCipher {
|
||||||
private static final int NONCE_BYTES = 12;
|
private static final int NONCE_BYTES = 12;
|
||||||
|
|||||||
+63
@@ -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.EncryptedSecret;
|
||||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
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.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.model.entity.SmtpConfiguration;
|
||||||
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
||||||
import org.springframework.core.env.Environment;
|
import org.springframework.core.env.Environment;
|
||||||
@@ -15,6 +16,10 @@ import org.springframework.core.env.Profiles;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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
|
@Service
|
||||||
public class SmtpConfigurationService {
|
public class SmtpConfigurationService {
|
||||||
private final SmtpConfigurationRepository configurations;
|
private final SmtpConfigurationRepository configurations;
|
||||||
@@ -37,6 +42,14 @@ public class SmtpConfigurationService {
|
|||||||
this.mailDelivery = mailDelivery;
|
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
|
@Transactional
|
||||||
public long saveDraft(long adminId, SmtpDraft draft) {
|
public long saveDraft(long adminId, SmtpDraft draft) {
|
||||||
validate(draft);
|
validate(draft);
|
||||||
@@ -52,6 +65,13 @@ public class SmtpConfigurationService {
|
|||||||
return configurations.save(configuration).getId();
|
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) {
|
public void testDraft(long draftId, long adminId, String recipient) {
|
||||||
SmtpConfiguration draft = configurations.findById(draftId)
|
SmtpConfiguration draft = configurations.findById(draftId)
|
||||||
.filter(configuration -> configuration.getStatus() == SmtpStatus.DRAFT)
|
.filter(configuration -> configuration.getStatus() == SmtpStatus.DRAFT)
|
||||||
@@ -62,6 +82,12 @@ public class SmtpConfigurationService {
|
|||||||
configurations.save(draft);
|
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
|
@Transactional
|
||||||
public void activate(long draftId, long adminId) {
|
public void activate(long draftId, long adminId) {
|
||||||
SmtpConfiguration draft = configurations.findWithLockByIdAndStatus(draftId, SmtpStatus.DRAFT)
|
SmtpConfiguration draft = configurations.findWithLockByIdAndStatus(draftId, SmtpStatus.DRAFT)
|
||||||
@@ -73,16 +99,53 @@ public class SmtpConfigurationService {
|
|||||||
draft.activate(verifiedAdminId, now);
|
draft.activate(verifiedAdminId, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return {@code true} when a tested SMTP revision is currently active */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public boolean hasActiveConfiguration() {
|
public boolean hasActiveConfiguration() {
|
||||||
return mailDelivery.isAvailable();
|
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)
|
@Transactional(readOnly = true)
|
||||||
public SmtpConnection activeConnection() {
|
public SmtpConnection activeConnection() {
|
||||||
return mailDelivery.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) {
|
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
|
||||||
mailDelivery.send(recipient, subject, body);
|
mailDelivery.send(recipient, subject, body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ package com.lab.labtimesheet.feature.integration.service;
|
|||||||
|
|
||||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||||
|
|
||||||
|
/** External SMTP adapter boundary used by setup tests and application email delivery. */
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface SmtpProbe {
|
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);
|
void send(SmtpConnection connection, String recipient, String subject, String body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,22 @@
|
|||||||
<main>
|
<main>
|
||||||
<p class="page-description">Use at least 12 characters. This activation link can be used once.</p>
|
<p class="page-description">Use at least 12 characters. This activation link can be used once.</p>
|
||||||
<p class="alert alert-error" th:if="${error}" th:text="${error}" role="alert"></p>
|
<p class="alert alert-error" th:if="${error}" th:text="${error}" role="alert"></p>
|
||||||
<form class="form-grid auth-form" method="post" th:action="@{/activate}">
|
<form class="form-grid auth-form" method="post" th:action="@{/activate}" th:object="${activationForm}">
|
||||||
<input name="token" type="hidden" th:value="${token}">
|
<div class="alert alert-error" th:if="${#fields.hasGlobalErrors()}" role="alert">
|
||||||
<div class="field"><label class="field-label" for="password">Password</label><input class="control" id="password" name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></div>
|
<p th:each="fieldError : ${#fields.globalErrors()}" th:text="${fieldError}"></p>
|
||||||
<div class="field"><label class="field-label" for="confirmPassword">Confirm password</label><input class="control" id="confirmPassword" name="confirmPassword" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></div>
|
</div>
|
||||||
|
<input th:field="*{token}" type="hidden">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="password">Password</label>
|
||||||
|
<input class="control" id="password" th:field="*{password}" type="password" minlength="12" maxlength="128" required autocomplete="new-password" th:attr="aria-invalid=${#fields.hasErrors('password')},aria-describedby=${#fields.hasErrors('password') ? 'activation-password-error' : null}">
|
||||||
|
<p class="field-error" id="activation-password-error" th:if="${#fields.hasErrors('password')}" th:errors="*{password}" role="alert"></p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="confirmPassword">Confirm password</label>
|
||||||
|
<input class="control" id="confirmPassword" th:field="*{confirmPassword}" type="password" minlength="12" maxlength="128" required autocomplete="new-password" th:attr="aria-invalid=${#fields.hasErrors('confirmPassword') or #fields.hasErrors('passwordConfirmed')},aria-describedby=${#fields.hasErrors('confirmPassword') ? 'activation-confirm-password-error' : (#fields.hasErrors('passwordConfirmed') ? 'activation-password-confirmed-error' : null)}">
|
||||||
|
<p class="field-error" id="activation-confirm-password-error" th:if="${#fields.hasErrors('confirmPassword')}" th:errors="*{confirmPassword}" role="alert"></p>
|
||||||
|
<p class="field-error" id="activation-password-confirmed-error" th:if="${#fields.hasErrors('passwordConfirmed')}" th:errors="*{passwordConfirmed}" role="alert"></p>
|
||||||
|
</div>
|
||||||
<button class="button button-primary" type="submit">Activate account</button>
|
<button class="button button-primary" type="submit">Activate account</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -10,27 +10,42 @@
|
|||||||
<a id="primary-action" class="button" th:href="@{/dashboard}">Back to overview</a>
|
<a id="primary-action" class="button" th:href="@{/dashboard}">Back to overview</a>
|
||||||
<main>
|
<main>
|
||||||
<p class="page-description">Create a role-specific account and send its one-time activation link.</p>
|
<p class="page-description">Create a role-specific account and send its one-time activation link.</p>
|
||||||
|
<p class="alert alert-error" th:if="${smtpRestricted}" role="alert">This is a restricted installation until tested SMTP is active.</p>
|
||||||
<p class="alert" th:if="${param.created}" role="status">Account created and activation email sent.</p>
|
<p class="alert" th:if="${param.created}" role="status">Account created and activation email sent.</p>
|
||||||
<p class="alert alert-error" th:if="${param.deliveryFailed}" role="alert">Account created, but activation delivery failed.</p>
|
<p class="alert alert-error" th:if="${param.deliveryFailed}" role="alert">Account created, but activation delivery failed.</p>
|
||||||
<p class="alert alert-error" th:if="${error}" th:text="${error}" role="alert"></p>
|
<form class="panel form-panel form-grid" method="post" th:action="@{/admin/accounts}" th:object="${accountForm}">
|
||||||
<form class="panel form-panel form-grid" method="post" th:action="@{/admin/accounts}">
|
<div class="alert alert-error" th:if="${#fields.hasAnyErrors()}" role="alert">
|
||||||
<div class="field"><label class="field-label" for="email">Email</label><input class="control" id="email" name="email" type="email" required autocomplete="off"></div>
|
<strong>Please correct the highlighted fields.</strong>
|
||||||
<div class="field"><label class="field-label" for="displayName">Display name</label><input class="control" id="displayName" name="displayName" required autocomplete="off"></div>
|
<ul><li th:each="fieldError : ${#fields.allErrors()}" th:text="${fieldError}"></li></ul>
|
||||||
<div class="field"><label class="field-label" for="role">Role</label>
|
</div>
|
||||||
<select class="control" id="role" name="role" required>
|
<div class="field">
|
||||||
|
<label class="field-label" for="email">Email</label>
|
||||||
|
<input class="control" id="email" th:field="*{email}" type="email" required autocomplete="off" th:attr="aria-invalid=${#fields.hasErrors('email')},aria-describedby=${#fields.hasErrors('email') ? 'account-email-error' : null}">
|
||||||
|
<p class="field-error" id="account-email-error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}" role="alert"></p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="displayName">Display name</label>
|
||||||
|
<input class="control" id="displayName" th:field="*{displayName}" required autocomplete="off" th:attr="aria-invalid=${#fields.hasErrors('displayName')},aria-describedby=${#fields.hasErrors('displayName') ? 'account-display-name-error' : null}">
|
||||||
|
<p class="field-error" id="account-display-name-error" th:if="${#fields.hasErrors('displayName')}" th:errors="*{displayName}" role="alert"></p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="role">Role</label>
|
||||||
|
<select class="control" id="role" th:field="*{role}" required th:attr="aria-invalid=${#fields.hasErrors('role')},aria-describedby=${#fields.hasErrors('role') ? 'account-role-error' : null}">
|
||||||
<option value="ADMIN">Admin</option>
|
<option value="ADMIN">Admin</option>
|
||||||
<option value="MENTOR">Mentor</option>
|
<option value="MENTOR">Mentor</option>
|
||||||
<option value="INTERN">Intern</option>
|
<option value="INTERN">Intern</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p class="field-error" id="account-role-error" th:if="${#fields.hasErrors('role')}" th:errors="*{role}" role="alert"></p>
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="form-section">
|
<fieldset class="form-section" th:attr="aria-describedby=${#fields.hasErrors('internDetailsValid') ? 'account-intern-details-error' : null}">
|
||||||
<legend>Intern details</legend>
|
<legend>Intern details</legend>
|
||||||
<p class="field-help">Required only when the selected role is Intern.</p>
|
<p class="field-help">Required only when the selected role is Intern.</p>
|
||||||
<div class="form-grid form-grid-three">
|
<div class="form-grid form-grid-three">
|
||||||
<div class="field"><label class="field-label" for="studentCode">Student code</label><input class="control" id="studentCode" name="studentCode" autocomplete="off"></div>
|
<div class="field"><label class="field-label" for="studentCode">Student code</label><input class="control" id="studentCode" th:field="*{studentCode}" autocomplete="off" th:attr="aria-invalid=${#fields.hasErrors('studentCode')},aria-describedby=${#fields.hasErrors('studentCode') ? 'account-student-code-error' : null}"><p class="field-error" id="account-student-code-error" th:if="${#fields.hasErrors('studentCode')}" th:errors="*{studentCode}" role="alert"></p></div>
|
||||||
<div class="field"><label class="field-label" for="internshipStart">Internship start</label><input class="control" id="internshipStart" name="internshipStart" type="date"></div>
|
<div class="field"><label class="field-label" for="internshipStart">Internship start</label><input class="control" id="internshipStart" th:field="*{internshipStart}" type="date"></div>
|
||||||
<div class="field"><label class="field-label" for="internshipEnd">Internship end</label><input class="control" id="internshipEnd" name="internshipEnd" type="date"></div>
|
<div class="field"><label class="field-label" for="internshipEnd">Internship end</label><input class="control" id="internshipEnd" th:field="*{internshipEnd}" type="date"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="field-error" id="account-intern-details-error" th:if="${#fields.hasErrors('internDetailsValid')}" th:errors="*{internDetailsValid}" role="alert"></p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div class="form-actions"><a class="button" th:href="@{/dashboard}">Cancel</a><button class="button button-primary" type="submit">Create account</button></div>
|
<div class="form-actions"><a class="button" th:href="@{/dashboard}">Cancel</a><button class="button button-primary" type="submit">Create account</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -8,10 +8,26 @@
|
|||||||
<main>
|
<main>
|
||||||
<p class="page-description">Initialize this installation once. Later accounts are created by an active Admin.</p>
|
<p class="page-description">Initialize this installation once. Later accounts are created by an active Admin.</p>
|
||||||
<p class="alert alert-error" th:if="${error}" th:text="${error}" role="alert"></p>
|
<p class="alert alert-error" th:if="${error}" th:text="${error}" role="alert"></p>
|
||||||
<form class="form-grid auth-form" method="post" th:action="@{/bootstrap}">
|
<form class="form-grid auth-form" method="post" th:action="@{/bootstrap}" th:object="${bootstrapForm}">
|
||||||
<div class="field"><label class="field-label" for="email">Email</label><input class="control" id="email" name="email" type="email" required autocomplete="email" autofocus></div>
|
<div class="alert alert-error" th:if="${#fields.hasAnyErrors()}" role="alert">
|
||||||
<div class="field"><label class="field-label" for="displayName">Display name</label><input class="control" id="displayName" name="displayName" required autocomplete="name"></div>
|
<strong>Please correct the highlighted fields.</strong>
|
||||||
<div class="field"><label class="field-label" for="password">Password</label><input class="control" id="password" name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></div>
|
<ul><li th:each="fieldError : ${#fields.allErrors()}" th:text="${fieldError}"></li></ul>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="email">Email</label>
|
||||||
|
<input class="control" id="email" th:field="*{email}" type="email" required autocomplete="email" autofocus th:attr="aria-invalid=${#fields.hasErrors('email')},aria-describedby=${#fields.hasErrors('email') ? 'bootstrap-email-error' : null}">
|
||||||
|
<p class="field-error" id="bootstrap-email-error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}" role="alert"></p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="displayName">Display name</label>
|
||||||
|
<input class="control" id="displayName" th:field="*{displayName}" required autocomplete="name" th:attr="aria-invalid=${#fields.hasErrors('displayName')},aria-describedby=${#fields.hasErrors('displayName') ? 'bootstrap-display-name-error' : null}">
|
||||||
|
<p class="field-error" id="bootstrap-display-name-error" th:if="${#fields.hasErrors('displayName')}" th:errors="*{displayName}" role="alert"></p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="password">Password</label>
|
||||||
|
<input class="control" id="password" th:field="*{password}" type="password" minlength="12" maxlength="128" required autocomplete="new-password" th:attr="aria-invalid=${#fields.hasErrors('password')},aria-describedby=${#fields.hasErrors('password') ? 'bootstrap-password-error' : null}">
|
||||||
|
<p class="field-error" id="bootstrap-password-error" th:if="${#fields.hasErrors('password')}" th:errors="*{password}" role="alert"></p>
|
||||||
|
</div>
|
||||||
<button class="button button-primary" type="submit">Create administrator</button>
|
<button class="button button-primary" type="submit">Create administrator</button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head><meta charset="utf-8"><title>Defer SMTP configuration</title></head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Defer SMTP configuration</h1>
|
||||||
|
<p role="alert" th:text="${deferralWarning}"></p>
|
||||||
|
<p th:text="|Confirmation ${deferralStep} of 5|"></p>
|
||||||
|
<a th:href="@{/admin/smtp}">Configure SMTP</a>
|
||||||
|
<form th:if="${deferralStep > 1}" method="post" th:action="@{/admin/smtp/defer/back}">
|
||||||
|
<button type="submit">Back</button>
|
||||||
|
</form>
|
||||||
|
<p th:if="${deferralStep == 1}"><a th:href="@{/admin/smtp}">Back</a></p>
|
||||||
|
<form th:if="${deferralStep < 5}" method="post" th:action="@{/admin/smtp/defer/next}">
|
||||||
|
<button type="submit">I understand; continue</button>
|
||||||
|
</form>
|
||||||
|
<form th:if="${deferralStep == 5}" method="post" th:action="@{/admin/smtp/defer/finish}">
|
||||||
|
<button type="submit">Finish without SMTP</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,16 +4,42 @@
|
|||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>SMTP configuration</h1>
|
<h1>SMTP configuration</h1>
|
||||||
<form method="post" th:action="@{/admin/smtp/draft}">
|
<p th:if="${smtpRestricted}" role="alert">This is a restricted installation until tested SMTP is active.</p>
|
||||||
<label>Host <input name="host" required></label>
|
<p th:if="${param.onboarding}">Configure SMTP now to enable account onboarding and recovery.</p>
|
||||||
<label>Port <input name="port" type="number" min="1" max="65535" required></label>
|
<p th:if="${smtpStatus.active}" role="status">SMTP is active.</p>
|
||||||
<label>Security <select name="securityMode"><option>STARTTLS</option><option>TLS</option><option>NONE</option></select></label>
|
<p th:if="${smtpStatus.draftId != null}" role="status">Draft saved.</p>
|
||||||
<label>Username <input name="username" autocomplete="username"></label>
|
<p th:if="${smtpStatus.tested}" role="status">Test passed.</p>
|
||||||
|
<p th:if="${smtpActionError}" th:text="${smtpActionError}" role="alert"></p>
|
||||||
|
<form method="post" th:action="@{/admin/smtp/draft}" th:object="${smtpForm}">
|
||||||
|
<div th:if="${#fields.hasGlobalErrors()}" role="alert">
|
||||||
|
<p th:each="error : ${#fields.globalErrors()}" th:text="${error}"></p>
|
||||||
|
</div>
|
||||||
|
<label>Host <input th:field="*{host}" required></label>
|
||||||
|
<p th:if="${#fields.hasErrors('host')}" th:errors="*{host}" role="alert"></p>
|
||||||
|
<label>Port <input th:field="*{port}" type="number" min="1" max="65535" required></label>
|
||||||
|
<p th:if="${#fields.hasErrors('port')}" th:errors="*{port}" role="alert"></p>
|
||||||
|
<label>Security <select th:field="*{securityMode}"><option value="STARTTLS">STARTTLS</option><option value="TLS">TLS</option><option value="NONE">NONE</option></select></label>
|
||||||
|
<p th:if="${#fields.hasErrors('securityMode')}" th:errors="*{securityMode}" role="alert"></p>
|
||||||
|
<label>Username <input th:field="*{username}" autocomplete="username"></label>
|
||||||
<label>Password <input name="password" type="password" autocomplete="new-password"></label>
|
<label>Password <input name="password" type="password" autocomplete="new-password"></label>
|
||||||
<label>From address <input name="fromAddress" type="email" required></label>
|
<p th:if="${#fields.hasErrors('authenticationComplete')}" th:errors="*{authenticationComplete}" role="alert"></p>
|
||||||
<label>From name <input name="fromName" required></label>
|
<label>From address <input th:field="*{fromAddress}" type="email" required></label>
|
||||||
|
<p th:if="${#fields.hasErrors('fromAddress')}" th:errors="*{fromAddress}" role="alert"></p>
|
||||||
|
<label>From name <input th:field="*{fromName}" required></label>
|
||||||
|
<p th:if="${#fields.hasErrors('fromName')}" th:errors="*{fromName}" role="alert"></p>
|
||||||
<button type="submit">Save draft</button>
|
<button type="submit">Save draft</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form th:if="${smtpStatus.draftId != null}" method="post" th:action="@{/admin/smtp/test}">
|
||||||
|
<input type="hidden" name="draftId" th:value="${smtpStatus.draftId}">
|
||||||
|
<button type="submit">Test connection</button>
|
||||||
|
</form>
|
||||||
|
<form th:if="${smtpStatus.draftId != null and smtpStatus.tested}" method="post" th:action="@{/admin/smtp/activate}">
|
||||||
|
<input type="hidden" name="draftId" th:value="${smtpStatus.draftId}">
|
||||||
|
<button type="submit">Activate SMTP</button>
|
||||||
|
</form>
|
||||||
|
<p th:if="${param.onboarding}">
|
||||||
|
<a th:href="@{/admin/smtp/defer}">Defer SMTP</a>
|
||||||
|
</p>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
-1
@@ -43,7 +43,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||||
class AccountWebIntegrationTest {
|
class AccountWebIntegrationTest {
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
@@ -139,6 +139,104 @@ class AccountWebIntegrationTest {
|
|||||||
.andExpect(unauthenticated());
|
.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)
|
@TestConfiguration(proxyBeanMethods = false)
|
||||||
static class MailProbeConfiguration {
|
static class MailProbeConfiguration {
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
+140
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -33,11 +33,13 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
import org.springframework.context.annotation.Primary;
|
import org.springframework.context.annotation.Primary;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.annotation.DirtiesContext;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
@Import({TestcontainersConfiguration.class, AccountActivationIntegrationTest.MailProbeConfiguration.class})
|
@Import({TestcontainersConfiguration.class, AccountActivationIntegrationTest.MailProbeConfiguration.class})
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
|
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||||
class AccountActivationIntegrationTest {
|
class AccountActivationIntegrationTest {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -138,6 +140,25 @@ class AccountActivationIntegrationTest {
|
|||||||
assertThat(summary.activeInternships()).isEqualTo(1);
|
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) {
|
private void activateSmtp(long adminId) {
|
||||||
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
|
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
|
||||||
"mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet"));
|
"mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet"));
|
||||||
|
|||||||
+32
@@ -11,13 +11,19 @@ import java.util.concurrent.CountDownLatch;
|
|||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.Future;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
|
import com.lab.labtimesheet.LabtimesheetApplication;
|
||||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||||
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
|
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.test.context.SpringBootTest;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
@@ -47,6 +53,9 @@ class BootstrapIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private SystemStateRepository systemStates;
|
private SystemStateRepository systemStates;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DataSource dataSource;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception {
|
void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception {
|
||||||
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
|
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
|
||||||
@@ -87,6 +96,29 @@ class BootstrapIntegrationTest {
|
|||||||
assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue();
|
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
|
@Test
|
||||||
void exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes() {
|
void exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes() {
|
||||||
bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple");
|
bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple");
|
||||||
|
|||||||
+199
@@ -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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
asset
|
||||||
Reference in New Issue
Block a user