feat(account): add SMTP-gated activation lifecycle
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# Test Evidence: SMTP-gated account creation and activation
|
||||
|
||||
- **Test type:** Integration
|
||||
- **Requirement IDs:** `ACC-008`–`ACC-014`, `ACC-019`, `ACC-020`, `NOT-008`, `SEC-005`, `SEC-007`
|
||||
- **Scenario IDs:** `AC-ACC-001`, `AC-ACC-002`, `AC-ACC-003`, `AC-ACC-007`
|
||||
- **Test class/method:** `com.lab.labtimesheet.feature.account.service.AccountActivationIntegrationTest#smtpGatedCreationHashesSingleUseActivationAndRetainsFailedDeliveryHistory`
|
||||
- **Implementation commit:** `this milestone commit`
|
||||
|
||||
## Protected behavior
|
||||
|
||||
An active Admin can create pending Mentor/Intern accounts only while a tested SMTP revision is active. The raw activation secret exists only in the immediate email, PostgreSQL stores only its SHA-256 hash, activation is single-use, and a failed initial delivery keeps history while invalidating that token. Activating an Intern's lifecycle separately makes the account eligible only inside its inclusive internship dates. Reporting reads account counts through the Account service boundary.
|
||||
|
||||
## Test method
|
||||
|
||||
The PostgreSQL 18.4 integration test bootstraps the first Admin, proves creation is blocked before SMTP activation, activates a recorded SMTP boundary, and exercises production account creation/activation. It independently hashes the captured raw link token, inspects persisted state through platform-owned repositories, simulates delivery failure, activates an Intern lifecycle, checks date boundaries, and checks the service-level summary used by reporting.
|
||||
|
||||
## Hand-derived expected result
|
||||
|
||||
The first non-bootstrap creation attempt adds zero rows. A delivered Mentor is pending with no password until one successful activation; replay fails. A failed Intern delivery leaves one pending account and one invalidated token. After the successful Intern is activated at both account and internship levels, the final state has three active accounts (Admin, Mentor, Intern), one pending account, and one active internship.
|
||||
|
||||
## RED
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@25 DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=AccountActivationIntegrationTest test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
BUILD FAILURE. Test compilation reported five missing account-activation API/model symbols, including CreateAccountCommand, TokenPurpose, and UserActionTokenRepository. No test ran.
|
||||
```
|
||||
|
||||
After the first GREEN implementation, the exact-expiry assertion was added and observed RED before exposing the persisted expiry:
|
||||
|
||||
```text
|
||||
BUILD FAILURE. AccountActivationIntegrationTest could not compile because UserActionToken#getExpiresAt() did not exist.
|
||||
```
|
||||
|
||||
## GREEN
|
||||
|
||||
**Command**
|
||||
|
||||
```text
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@25 DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw -Dtest=AccountActivationIntegrationTest test
|
||||
```
|
||||
|
||||
**Observed result**
|
||||
|
||||
```text
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
```
|
||||
|
||||
## Affected suite
|
||||
|
||||
**Command and result**
|
||||
|
||||
```text
|
||||
JAVA_HOME=/opt/homebrew/opt/openjdk@25 DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw test
|
||||
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
|
||||
BUILD SUCCESS
|
||||
```
|
||||
|
||||
## External-test boundaries
|
||||
|
||||
The recording SMTP boundary proves the exact in-memory handoff but not Mailpit/network delivery or a browser following the link. MVC activation forms, resend, password reset, session invalidation, lock/deactivation, and production origin/readiness hardening remain separate Iteration 1 or later slices.
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.lab.labtimesheet.feature.account.model;
|
||||
|
||||
public enum TokenPurpose {
|
||||
ACTIVATION,
|
||||
PASSWORD_RESET
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
public record AccountCreation(long userId, boolean deliverySucceeded) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
public record AccountSummary(long activeAccounts, long pendingActivations, long activeInternships) {
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
|
||||
public record CreateAccountCommand(
|
||||
String email,
|
||||
String displayName,
|
||||
GlobalRole role,
|
||||
String studentCode,
|
||||
LocalDate internshipStart,
|
||||
LocalDate internshipEnd) {
|
||||
}
|
||||
@@ -77,6 +77,22 @@ public class AppUser {
|
||||
return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now);
|
||||
}
|
||||
|
||||
public static AppUser pending(
|
||||
String email, String displayName, GlobalRole globalRole, AppUser createdBy, Instant now) {
|
||||
return new AppUser(
|
||||
email, displayName, null, globalRole, AccountStatus.PENDING_ACTIVATION, null, createdBy, now);
|
||||
}
|
||||
|
||||
public void activate(String encodedPassword, Instant now) {
|
||||
if (accountStatus != AccountStatus.PENDING_ACTIVATION) {
|
||||
throw new IllegalStateException("Only a pending account can activate");
|
||||
}
|
||||
passwordHash = encodedPassword;
|
||||
accountStatus = AccountStatus.ACTIVE;
|
||||
activatedAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -100,4 +116,8 @@ public class AppUser {
|
||||
public AccountStatus getAccountStatus() {
|
||||
return accountStatus;
|
||||
}
|
||||
|
||||
public Instant getActivatedAt() {
|
||||
return activatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,4 +58,41 @@ public class InternProfile {
|
||||
|
||||
protected InternProfile() {
|
||||
}
|
||||
|
||||
private InternProfile(
|
||||
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
||||
this.userId = userId;
|
||||
this.studentCode = studentCode;
|
||||
this.internshipStartDate = internshipStartDate;
|
||||
this.internshipEndDate = internshipEndDate;
|
||||
this.internshipStatus = InternshipStatus.NOT_STARTED;
|
||||
this.createdAt = now;
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
public static InternProfile notStarted(
|
||||
long userId, String studentCode, LocalDate internshipStartDate, LocalDate internshipEndDate, Instant now) {
|
||||
return new InternProfile(userId, studentCode, internshipStartDate, internshipEndDate, now);
|
||||
}
|
||||
|
||||
public void activate(Instant now) {
|
||||
if (internshipStatus != InternshipStatus.NOT_STARTED) {
|
||||
throw new IllegalStateException("Only a not-started internship can activate");
|
||||
}
|
||||
internshipStatus = InternshipStatus.ACTIVE;
|
||||
activatedAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
public InternshipStatus getInternshipStatus() {
|
||||
return internshipStatus;
|
||||
}
|
||||
|
||||
public LocalDate getInternshipStartDate() {
|
||||
return internshipStartDate;
|
||||
}
|
||||
|
||||
public LocalDate getInternshipEndDate() {
|
||||
return internshipEndDate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.lab.labtimesheet.feature.account.model.entity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_action_tokens")
|
||||
public class UserActionToken {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 24)
|
||||
private TokenPurpose purpose;
|
||||
|
||||
@Column(name = "token_hash", nullable = false, columnDefinition = "bytea")
|
||||
private byte[] tokenHash;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private Instant expiresAt;
|
||||
|
||||
@Column(name = "used_at")
|
||||
private Instant usedAt;
|
||||
|
||||
@Column(name = "invalidated_at")
|
||||
private Instant invalidatedAt;
|
||||
|
||||
@Column(name = "issued_by_user_id")
|
||||
private Long issuedByUserId;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
protected UserActionToken() {
|
||||
}
|
||||
|
||||
private UserActionToken(long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
||||
this.userId = userId;
|
||||
this.purpose = TokenPurpose.ACTIVATION;
|
||||
this.tokenHash = Arrays.copyOf(tokenHash, tokenHash.length);
|
||||
this.expiresAt = expiresAt;
|
||||
this.issuedByUserId = issuedByUserId;
|
||||
this.createdAt = now;
|
||||
}
|
||||
|
||||
public static UserActionToken activation(
|
||||
long userId, byte[] tokenHash, Instant expiresAt, long issuedByUserId, Instant now) {
|
||||
return new UserActionToken(userId, tokenHash, expiresAt, issuedByUserId, now);
|
||||
}
|
||||
|
||||
public boolean isUsableAt(Instant now) {
|
||||
return usedAt == null && invalidatedAt == null && now.isBefore(expiresAt);
|
||||
}
|
||||
|
||||
public void markUsed(Instant now) {
|
||||
if (!isUsableAt(now)) {
|
||||
throw new IllegalStateException("Activation token is not usable");
|
||||
}
|
||||
usedAt = now;
|
||||
}
|
||||
|
||||
public void invalidate(Instant now) {
|
||||
if (usedAt != null) {
|
||||
throw new IllegalStateException("A used token cannot be invalidated");
|
||||
}
|
||||
if (invalidatedAt == null) {
|
||||
invalidatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public TokenPurpose getPurpose() {
|
||||
return purpose;
|
||||
}
|
||||
|
||||
public byte[] getTokenHash() {
|
||||
return Arrays.copyOf(tokenHash, tokenHash.length);
|
||||
}
|
||||
|
||||
public Instant getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public Instant getUsedAt() {
|
||||
return usedAt;
|
||||
}
|
||||
|
||||
public Instant getInvalidatedAt() {
|
||||
return invalidatedAt;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import java.util.Optional;
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
@@ -13,5 +15,11 @@ public interface AppUserRepository extends JpaRepository<AppUser, Long> {
|
||||
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
|
||||
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select u from AppUser u where u.id = :id")
|
||||
Optional<AppUser> findForUpdateById(@Param("id") Long id);
|
||||
|
||||
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
|
||||
|
||||
long countByAccountStatus(AccountStatus status);
|
||||
}
|
||||
|
||||
+10
@@ -4,11 +4,21 @@ import java.time.LocalDate;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
|
||||
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
|
||||
|
||||
boolean existsByUserIdAndInternshipStatusAndInternshipStartDateLessThanEqualAndInternshipEndDateGreaterThanEqual(
|
||||
Long userId, InternshipStatus status, LocalDate latestStartDate, LocalDate earliestEndDate);
|
||||
|
||||
long countByInternshipStatus(InternshipStatus status);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select p from InternProfile p where p.userId = :userId")
|
||||
java.util.Optional<InternProfile> findForUpdateByUserId(@Param("userId") Long userId);
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.lab.labtimesheet.feature.account.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface UserActionTokenRepository extends JpaRepository<UserActionToken, Long> {
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select t from UserActionToken t where t.tokenHash = :hash and t.purpose = :purpose")
|
||||
Optional<UserActionToken> findForUpdateByHashAndPurpose(
|
||||
@Param("hash") byte[] hash, @Param("purpose") TokenPurpose purpose);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select t from UserActionToken t where t.id = :id")
|
||||
Optional<UserActionToken> findForUpdateById(@Param("id") Long id);
|
||||
}
|
||||
@@ -1,25 +1,140 @@
|
||||
package com.lab.labtimesheet.feature.account.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Base64;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.AccountCreation;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.AccountSummary;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.UserActionToken;
|
||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.UserActionTokenRepository;
|
||||
import com.lab.labtimesheet.feature.integration.service.MailDeliveryService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
@Service
|
||||
public class AccountService {
|
||||
private static final Duration ACTIVATION_LIFETIME = Duration.ofHours(24);
|
||||
private static final SecureRandom TOKEN_RANDOM = new SecureRandom();
|
||||
|
||||
private final AppUserRepository users;
|
||||
private final InternProfileRepository internProfiles;
|
||||
private final UserActionTokenRepository tokens;
|
||||
private final MailDeliveryService mailDelivery;
|
||||
private final PasswordEncoder passwords;
|
||||
private final Clock clock;
|
||||
private final TransactionTemplate transactions;
|
||||
private final String publicOrigin;
|
||||
|
||||
AccountService(AppUserRepository users, InternProfileRepository internProfiles) {
|
||||
AccountService(
|
||||
AppUserRepository users,
|
||||
InternProfileRepository internProfiles,
|
||||
UserActionTokenRepository tokens,
|
||||
MailDeliveryService mailDelivery,
|
||||
PasswordEncoder passwords,
|
||||
Clock clock,
|
||||
TransactionTemplate transactions,
|
||||
@Value("${lab.public-origin}") String publicOrigin) {
|
||||
this.users = users;
|
||||
this.internProfiles = internProfiles;
|
||||
this.tokens = tokens;
|
||||
this.mailDelivery = mailDelivery;
|
||||
this.passwords = passwords;
|
||||
this.clock = clock;
|
||||
this.transactions = transactions;
|
||||
this.publicOrigin = normalizeOrigin(publicOrigin);
|
||||
}
|
||||
|
||||
public AccountCreation create(CreateAccountCommand command, long adminId) {
|
||||
ValidatedAccount account = validate(command);
|
||||
if (!mailDelivery.isAvailable()) {
|
||||
throw new IllegalStateException("Active SMTP configuration is required for account creation");
|
||||
}
|
||||
|
||||
String rawToken = newRawToken();
|
||||
byte[] tokenHash = sha256(rawToken);
|
||||
PendingActivation pending = transactions.execute(status -> createPending(account, adminId, tokenHash));
|
||||
if (pending == null) {
|
||||
throw new IllegalStateException("Account creation did not complete");
|
||||
}
|
||||
|
||||
try {
|
||||
mailDelivery.send(
|
||||
account.email(),
|
||||
"Activate your Lab Timesheet account",
|
||||
"Activate your account using this single-use link:\n" + activationLink(rawToken));
|
||||
return new AccountCreation(pending.userId(), true);
|
||||
} catch (RuntimeException deliveryFailure) {
|
||||
transactions.executeWithoutResult(status -> tokens.findForUpdateById(pending.tokenId())
|
||||
.orElseThrow(() -> new IllegalStateException("Activation token is missing"))
|
||||
.invalidate(clock.instant()));
|
||||
return new AccountCreation(pending.userId(), false);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean activate(String rawToken, String password) {
|
||||
BootstrapService.requirePassword(password);
|
||||
if (rawToken == null || rawToken.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UserActionToken token = tokens.findForUpdateByHashAndPurpose(sha256(rawToken), TokenPurpose.ACTIVATION)
|
||||
.orElse(null);
|
||||
var now = clock.instant();
|
||||
if (token == null || !token.isUsableAt(now)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AppUser user = users.findForUpdateById(token.getUserId()).orElse(null);
|
||||
if (user == null || user.getAccountStatus() != AccountStatus.PENDING_ACTIVATION) {
|
||||
return false;
|
||||
}
|
||||
user.activate(passwords.encode(password), now);
|
||||
token.markUsed(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void activateInternship(long internUserId, long adminId) {
|
||||
AppUser admin = users.findById(adminId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
|
||||
requireActiveAdmin(admin);
|
||||
|
||||
AppUser intern = users.findForUpdateById(internUserId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Intern not found"));
|
||||
if (intern.getGlobalRole() != GlobalRole.INTERN || intern.getAccountStatus() != AccountStatus.ACTIVE) {
|
||||
throw new IllegalArgumentException("An active Intern account is required");
|
||||
}
|
||||
internProfiles.findForUpdateByUserId(internUserId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Intern profile not found"))
|
||||
.activate(clock.instant());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AccountSummary summary() {
|
||||
return new AccountSummary(
|
||||
users.countByAccountStatus(AccountStatus.ACTIVE),
|
||||
users.countByAccountStatus(AccountStatus.PENDING_ACTIVATION),
|
||||
internProfiles.countByInternshipStatus(InternshipStatus.ACTIVE));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -91,4 +206,91 @@ public class AccountService {
|
||||
return new AccountIdentity(
|
||||
user.getId(), user.getEmail(), user.getDisplayName(), user.getGlobalRole(), user.getAccountStatus());
|
||||
}
|
||||
|
||||
private PendingActivation createPending(ValidatedAccount account, long adminId, byte[] tokenHash) {
|
||||
AppUser admin = users.findForUpdateById(adminId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
|
||||
requireActiveAdmin(admin);
|
||||
|
||||
var now = clock.instant();
|
||||
AppUser user = users.save(AppUser.pending(
|
||||
account.email(), account.displayName(), account.role(), admin, now));
|
||||
if (account.role() == GlobalRole.INTERN) {
|
||||
internProfiles.save(InternProfile.notStarted(
|
||||
user.getId(), account.studentCode(), account.internshipStart(), account.internshipEnd(), now));
|
||||
}
|
||||
UserActionToken token = tokens.save(UserActionToken.activation(
|
||||
user.getId(), tokenHash, now.plus(ACTIVATION_LIFETIME), admin.getId(), now));
|
||||
return new PendingActivation(user.getId(), token.getId());
|
||||
}
|
||||
|
||||
private String activationLink(String rawToken) {
|
||||
return publicOrigin + "/activate?token=" + rawToken;
|
||||
}
|
||||
|
||||
private static ValidatedAccount validate(CreateAccountCommand command) {
|
||||
if (command == null || command.role() == null) {
|
||||
throw new IllegalArgumentException("Account role is required");
|
||||
}
|
||||
String email = BootstrapService.normalizeEmail(command.email());
|
||||
String displayName = requireText(command.displayName(), "Display name");
|
||||
if (command.role() != GlobalRole.INTERN) {
|
||||
if (command.studentCode() != null || command.internshipStart() != null || command.internshipEnd() != null) {
|
||||
throw new IllegalArgumentException("Internship fields are allowed only for Intern accounts");
|
||||
}
|
||||
return new ValidatedAccount(email, displayName, command.role(), null, null, null);
|
||||
}
|
||||
|
||||
String studentCode = requireText(command.studentCode(), "Student code");
|
||||
if (command.internshipStart() == null || command.internshipEnd() == null
|
||||
|| command.internshipEnd().isBefore(command.internshipStart())) {
|
||||
throw new IllegalArgumentException("A valid internship date range is required");
|
||||
}
|
||||
return new ValidatedAccount(
|
||||
email, displayName, command.role(), studentCode, command.internshipStart(), command.internshipEnd());
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " is required");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeOrigin(String value) {
|
||||
String origin = requireText(value, "Public origin");
|
||||
while (origin.endsWith("/")) {
|
||||
origin = origin.substring(0, origin.length() - 1);
|
||||
}
|
||||
if (!origin.startsWith("http://") && !origin.startsWith("https://")) {
|
||||
throw new IllegalArgumentException("Public origin must use HTTP or HTTPS");
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
|
||||
private static String newRawToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
TOKEN_RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private static byte[] sha256(String value) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private record ValidatedAccount(
|
||||
String email,
|
||||
String displayName,
|
||||
GlobalRole role,
|
||||
String studentCode,
|
||||
LocalDate internshipStart,
|
||||
LocalDate internshipEnd) {
|
||||
}
|
||||
|
||||
private record PendingActivation(long userId, long tokenId) {
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.lab.labtimesheet.feature.integration.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
|
||||
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
|
||||
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class MailDeliveryService {
|
||||
private final SmtpConfigurationRepository configurations;
|
||||
private final SecretCipher secrets;
|
||||
private final SmtpProbe probe;
|
||||
|
||||
MailDeliveryService(SmtpConfigurationRepository configurations, SecretCipher secrets, SmtpProbe probe) {
|
||||
this.configurations = configurations;
|
||||
this.secrets = secrets;
|
||||
this.probe = probe;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isAvailable() {
|
||||
return configurations.existsByStatus(SmtpStatus.ACTIVE);
|
||||
}
|
||||
|
||||
public void send(String recipient, String subject, String body) {
|
||||
probe.send(activeConnection(), recipient, subject, body);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SmtpConnection activeConnection() {
|
||||
return configurations.findByStatus(SmtpStatus.ACTIVE)
|
||||
.map(this::connection)
|
||||
.orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required"));
|
||||
}
|
||||
|
||||
SmtpConnection connection(SmtpConfiguration configuration) {
|
||||
byte[] ciphertext = configuration.getPasswordCiphertext();
|
||||
return new SmtpConnection(
|
||||
configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(),
|
||||
configuration.getUsername(),
|
||||
ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()),
|
||||
configuration.getFromAddress(), configuration.getFromName());
|
||||
}
|
||||
}
|
||||
+8
-12
@@ -23,15 +23,18 @@ public class SmtpConfigurationService {
|
||||
private final SmtpProbe probe;
|
||||
private final Environment environment;
|
||||
private final Clock clock;
|
||||
private final MailDeliveryService mailDelivery;
|
||||
|
||||
SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts,
|
||||
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock) {
|
||||
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock,
|
||||
MailDeliveryService mailDelivery) {
|
||||
this.configurations = configurations;
|
||||
this.accounts = accounts;
|
||||
this.secrets = secrets;
|
||||
this.probe = probe;
|
||||
this.environment = environment;
|
||||
this.clock = clock;
|
||||
this.mailDelivery = mailDelivery;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -72,27 +75,20 @@ public class SmtpConfigurationService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean hasActiveConfiguration() {
|
||||
return configurations.existsByStatus(SmtpStatus.ACTIVE);
|
||||
return mailDelivery.isAvailable();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SmtpConnection activeConnection() {
|
||||
return configurations.findByStatus(SmtpStatus.ACTIVE)
|
||||
.map(this::connection)
|
||||
.orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required"));
|
||||
return mailDelivery.activeConnection();
|
||||
}
|
||||
|
||||
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
|
||||
probe.send(activeConnection(), recipient, subject, body);
|
||||
mailDelivery.send(recipient, subject, body);
|
||||
}
|
||||
|
||||
private SmtpConnection connection(SmtpConfiguration configuration) {
|
||||
byte[] ciphertext = configuration.getPasswordCiphertext();
|
||||
return new SmtpConnection(
|
||||
configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(),
|
||||
configuration.getUsername(),
|
||||
ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()),
|
||||
configuration.getFromAddress(), configuration.getFromName());
|
||||
return mailDelivery.connection(configuration);
|
||||
}
|
||||
|
||||
private void validate(SmtpDraft draft) {
|
||||
|
||||
@@ -7,6 +7,7 @@ spring:
|
||||
host: ${LAB_SMTP_HOST:localhost}
|
||||
port: ${LAB_SMTP_PORT:1025}
|
||||
lab:
|
||||
public-origin: ${LAB_PUBLIC_ORIGIN:http://localhost:8080}
|
||||
security:
|
||||
# Explicit non-production key; production must supply its own 256-bit key.
|
||||
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.lab.labtimesheet.feature.account.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.TokenPurpose;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.CreateAccountCommand;
|
||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.UserActionTokenRepository;
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpProbe;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@Import({TestcontainersConfiguration.class, AccountActivationIntegrationTest.MailProbeConfiguration.class})
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class AccountActivationIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private BootstrapService bootstrap;
|
||||
|
||||
@Autowired
|
||||
private AccountService accounts;
|
||||
|
||||
@Autowired
|
||||
private SmtpConfigurationService smtp;
|
||||
|
||||
@Autowired
|
||||
private RecordingSmtpProbe mail;
|
||||
|
||||
@Autowired
|
||||
private AppUserRepository users;
|
||||
|
||||
@Autowired
|
||||
private InternProfileRepository internProfiles;
|
||||
|
||||
@Autowired
|
||||
private UserActionTokenRepository tokens;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwords;
|
||||
|
||||
@Test
|
||||
void smtpGatedCreationHashesSingleUseActivationAndRetainsFailedDeliveryHistory() throws Exception {
|
||||
bootstrap.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||
long adminId = accounts.requireActiveAdminId("admin@example.com");
|
||||
|
||||
var mentor = new CreateAccountCommand(
|
||||
" MENTOR@EXAMPLE.COM ", " Mentor One ", GlobalRole.MENTOR, null, null, null);
|
||||
assertThatThrownBy(() -> accounts.create(mentor, adminId))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("SMTP");
|
||||
assertThat(users.count()).isEqualTo(1);
|
||||
|
||||
activateSmtp(adminId);
|
||||
mail.messages.clear();
|
||||
|
||||
var mentorCreation = accounts.create(mentor, adminId);
|
||||
assertThat(mentorCreation.deliverySucceeded()).isTrue();
|
||||
var pendingMentor = users.findById(mentorCreation.userId()).orElseThrow();
|
||||
assertThat(pendingMentor.getEmail()).isEqualTo("mentor@example.com");
|
||||
assertThat(pendingMentor.getDisplayName()).isEqualTo("Mentor One");
|
||||
assertThat(pendingMentor.getGlobalRole()).isEqualTo(GlobalRole.MENTOR);
|
||||
assertThat(pendingMentor.getAccountStatus()).isEqualTo(AccountStatus.PENDING_ACTIVATION);
|
||||
assertThat(pendingMentor.getPasswordHash()).isNull();
|
||||
|
||||
String rawMentorToken = mail.onlyActivationToken();
|
||||
var mentorToken = tokens.findAll().stream()
|
||||
.filter(token -> token.getUserId().equals(mentorCreation.userId()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertThat(mentorToken.getPurpose()).isEqualTo(TokenPurpose.ACTIVATION);
|
||||
assertThat(mentorToken.getTokenHash()).containsExactly(sha256(rawMentorToken));
|
||||
assertThat(mentorToken.getExpiresAt()).isEqualTo(Instant.parse("2026-08-15T00:00:00Z"));
|
||||
assertThat(mentorToken.isUsableAt(mentorToken.getExpiresAt())).isFalse();
|
||||
assertThat(HexFormat.of().formatHex(mentorToken.getTokenHash())).doesNotContain(rawMentorToken);
|
||||
|
||||
assertThat(accounts.activate("not-the-token", "new secure mentor password")).isFalse();
|
||||
assertThat(accounts.activate(rawMentorToken, "new secure mentor password")).isTrue();
|
||||
assertThat(accounts.activate(rawMentorToken, "another secure password")).isFalse();
|
||||
var activeMentor = users.findById(mentorCreation.userId()).orElseThrow();
|
||||
assertThat(activeMentor.getAccountStatus()).isEqualTo(AccountStatus.ACTIVE);
|
||||
assertThat(passwords.matches("new secure mentor password", activeMentor.getPasswordHash())).isTrue();
|
||||
assertThat(tokens.findById(mentorToken.getId()).orElseThrow().getUsedAt()).isNotNull();
|
||||
|
||||
mail.fail = true;
|
||||
var failedIntern = accounts.create(new CreateAccountCommand(
|
||||
"intern-failed@example.com", "Failed Intern", GlobalRole.INTERN, "STU-FAIL",
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId);
|
||||
assertThat(failedIntern.deliverySucceeded()).isFalse();
|
||||
assertThat(users.findById(failedIntern.userId()).orElseThrow().getAccountStatus())
|
||||
.isEqualTo(AccountStatus.PENDING_ACTIVATION);
|
||||
assertThat(tokens.findAll().stream()
|
||||
.filter(token -> token.getUserId().equals(failedIntern.userId()))
|
||||
.findFirst().orElseThrow().getInvalidatedAt()).isNotNull();
|
||||
|
||||
mail.fail = false;
|
||||
mail.messages.clear();
|
||||
var activeInternCreation = accounts.create(new CreateAccountCommand(
|
||||
"intern@example.com", "Active Intern", GlobalRole.INTERN, "STU-001",
|
||||
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 12, 31)), adminId);
|
||||
assertThat(accounts.activate(mail.onlyActivationToken(), "new secure intern password")).isTrue();
|
||||
accounts.activateInternship(activeInternCreation.userId(), adminId);
|
||||
|
||||
var profile = internProfiles.findById(activeInternCreation.userId()).orElseThrow();
|
||||
assertThat(profile.getInternshipStatus()).isEqualTo(InternshipStatus.ACTIVE);
|
||||
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 8, 1))).isTrue();
|
||||
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2026, 12, 31))).isTrue();
|
||||
assertThat(accounts.isEligibleIntern(activeInternCreation.userId(), LocalDate.of(2027, 1, 1))).isFalse();
|
||||
|
||||
var summary = accounts.summary();
|
||||
assertThat(summary.activeAccounts()).isEqualTo(3);
|
||||
assertThat(summary.pendingActivations()).isEqualTo(1);
|
||||
assertThat(summary.activeInternships()).isEqualTo(1);
|
||||
}
|
||||
|
||||
private void activateSmtp(long adminId) {
|
||||
long draftId = smtp.saveDraft(adminId, new SmtpDraft(
|
||||
"mailpit", 1025, SecurityMode.NONE, null, null, "admin@example.com", "Lab Timesheet"));
|
||||
smtp.testDraft(draftId, adminId, "admin@example.com");
|
||||
smtp.activate(draftId, adminId);
|
||||
}
|
||||
|
||||
private static byte[] sha256(String value) throws Exception {
|
||||
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
static class MailProbeConfiguration {
|
||||
@Bean
|
||||
@Primary
|
||||
RecordingSmtpProbe recordingSmtpProbe() {
|
||||
return new RecordingSmtpProbe();
|
||||
}
|
||||
}
|
||||
|
||||
static final class RecordingSmtpProbe implements SmtpProbe {
|
||||
private final List<Message> messages = new ArrayList<>();
|
||||
private boolean fail;
|
||||
|
||||
@Override
|
||||
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||
if (fail) {
|
||||
throw new IllegalStateException("simulated SMTP failure");
|
||||
}
|
||||
messages.add(new Message(recipient, subject, body));
|
||||
}
|
||||
|
||||
String onlyActivationToken() {
|
||||
assertThat(messages).hasSize(1);
|
||||
String body = messages.getFirst().body();
|
||||
int tokenStart = body.indexOf("token=");
|
||||
assertThat(tokenStart).isGreaterThanOrEqualTo(0);
|
||||
return body.substring(tokenStart + "token=".length()).trim();
|
||||
}
|
||||
}
|
||||
|
||||
record Message(String recipient, String subject, String body) {
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@ spring:
|
||||
compose:
|
||||
enabled: false
|
||||
lab:
|
||||
public-origin: http://localhost:8080
|
||||
security:
|
||||
master-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
|
||||
|
||||
Reference in New Issue
Block a user