refactor: adopt feature package boundaries and JPA
This commit is contained in:
@@ -4,7 +4,7 @@ import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
import com.lab.labtimesheet.configuration.SecurityProperties;
|
||||
import com.lab.labtimesheet.config.SecurityProperties;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties(SecurityProperties.class)
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package com.lab.labtimesheet.accounts;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
@Service
|
||||
public class BootstrapService {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final TransactionTemplate transactions;
|
||||
private final PasswordEncoder passwords;
|
||||
private final Clock clock;
|
||||
|
||||
BootstrapService(JdbcTemplate jdbc, TransactionTemplate transactions, PasswordEncoder passwords, Clock clock) {
|
||||
this.jdbc = jdbc;
|
||||
this.transactions = transactions;
|
||||
this.passwords = passwords;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
|
||||
String normalizedEmail = normalizeEmail(email);
|
||||
String normalizedName = requireText(displayName, "Display name");
|
||||
requirePassword(password);
|
||||
|
||||
return transactions.execute(status -> {
|
||||
Boolean initialized = jdbc.queryForObject(
|
||||
"select initialized from system_state where singleton_id = 1 for update", Boolean.class);
|
||||
if (Boolean.TRUE.equals(initialized)) {
|
||||
return BootstrapOutcome.ALREADY_INITIALIZED;
|
||||
}
|
||||
|
||||
OffsetDateTime now = OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
|
||||
Long userId = jdbc.queryForObject("""
|
||||
insert into app_users
|
||||
(email, display_name, password_hash, global_role, account_status, activated_at, created_at, updated_at)
|
||||
values (?, ?, ?, 'ADMIN', 'ACTIVE', ?, ?, ?)
|
||||
returning id
|
||||
""", Long.class, normalizedEmail, normalizedName, passwords.encode(password), now, now, now);
|
||||
jdbc.update("""
|
||||
update system_state
|
||||
set initialized = true, initialized_at = ?, bootstrap_admin_id = ?, updated_at = ?, version = version + 1
|
||||
where singleton_id = 1
|
||||
""", now, userId, now);
|
||||
return BootstrapOutcome.CREATED;
|
||||
});
|
||||
}
|
||||
|
||||
public boolean isInitialized() {
|
||||
return Boolean.TRUE.equals(jdbc.queryForObject(
|
||||
"select initialized from system_state where singleton_id = 1", Boolean.class));
|
||||
}
|
||||
|
||||
static String normalizeEmail(String email) {
|
||||
return requireText(email, "Email").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
static void requirePassword(String password) {
|
||||
if (password == null || password.length() < 12 || password.length() > 128) {
|
||||
throw new IllegalArgumentException("Password must contain 12 through 128 characters");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " is required");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
public enum BootstrapOutcome {
|
||||
CREATED,
|
||||
ALREADY_INITIALIZED
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.lab.labtimesheet.accounts;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
class JdbcUserDetailsService implements UserDetailsService {
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
JdbcUserDetailsService(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
String email = BootstrapService.normalizeEmail(username);
|
||||
return jdbc.query("""
|
||||
select email, password_hash, global_role, account_status
|
||||
from app_users where lower(btrim(email)) = ?
|
||||
""", resultSet -> {
|
||||
if (!resultSet.next()) {
|
||||
throw new UsernameNotFoundException("Invalid credentials");
|
||||
}
|
||||
boolean active = "ACTIVE".equals(resultSet.getString("account_status"));
|
||||
String hash = resultSet.getString("password_hash");
|
||||
return User.withUsername(resultSet.getString("email"))
|
||||
.password(hash == null ? "{noop}unavailable" : hash)
|
||||
.roles(resultSet.getString("global_role"))
|
||||
.disabled(!active)
|
||||
.build();
|
||||
}, email);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.lab.labtimesheet.accounts;
|
||||
|
||||
/** Accounts and security module boundary. */
|
||||
public final class ModuleBoundary {
|
||||
private ModuleBoundary() {
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.lab.labtimesheet.attendance;
|
||||
|
||||
/** Attendance, leave, and corrections module boundary. */
|
||||
public final class ModuleBoundary {
|
||||
private ModuleBoundary() {
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1,5 +1,7 @@
|
||||
package com.lab.labtimesheet.accounts;
|
||||
package com.lab.labtimesheet.config;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.controller.BootstrapAccessFilter;
|
||||
import com.lab.labtimesheet.feature.account.service.BootstrapService;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
@@ -10,7 +12,6 @@ import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
class SecurityConfiguration {
|
||||
|
||||
@Bean
|
||||
PasswordEncoder passwordEncoder() {
|
||||
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet.configuration;
|
||||
package com.lab.labtimesheet.config;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
@@ -16,7 +16,7 @@ public class SecurityProperties {
|
||||
this.masterKey = masterKey;
|
||||
}
|
||||
|
||||
byte[] decodedMasterKey() {
|
||||
public byte[] decodedMasterKey() {
|
||||
if (masterKey == null || masterKey.isBlank()) {
|
||||
throw new IllegalStateException("lab.security.master-key is required");
|
||||
}
|
||||
+1
-2
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet.configuration;
|
||||
package com.lab.labtimesheet.config;
|
||||
|
||||
import java.time.Clock;
|
||||
|
||||
@@ -7,7 +7,6 @@ import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
class TimeConfiguration {
|
||||
|
||||
@Bean
|
||||
Clock applicationClock() {
|
||||
return Clock.systemUTC();
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.lab.labtimesheet.configuration;
|
||||
|
||||
/** Configuration, integrations, and calendar module boundary. */
|
||||
public final class ModuleBoundary {
|
||||
private ModuleBoundary() {
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package com.lab.labtimesheet.configuration;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
@Service
|
||||
public class SmtpConfigurationService {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final TransactionTemplate transactions;
|
||||
private final SecretCipher secrets;
|
||||
private final SmtpProbe probe;
|
||||
private final Environment environment;
|
||||
private final Clock clock;
|
||||
|
||||
SmtpConfigurationService(JdbcTemplate jdbc, TransactionTemplate transactions, SecretCipher secrets,
|
||||
SmtpProbe probe, Environment environment, Clock clock) {
|
||||
this.jdbc = jdbc;
|
||||
this.transactions = transactions;
|
||||
this.secrets = secrets;
|
||||
this.probe = probe;
|
||||
this.environment = environment;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public long saveDraft(long adminId, SmtpDraft draft) {
|
||||
validate(draft);
|
||||
SecretCipher.EncryptedSecret password = draft.password() == null ? null : secrets.encrypt(draft.password());
|
||||
OffsetDateTime now = now();
|
||||
|
||||
return transactions.execute(status -> {
|
||||
Long existing = jdbc.query("select id from smtp_configurations where status = 'DRAFT' for update",
|
||||
resultSet -> resultSet.next() ? resultSet.getLong(1) : null);
|
||||
Object[] values = values(draft, password, adminId, now);
|
||||
if (existing == null) {
|
||||
return jdbc.queryForObject("""
|
||||
insert into smtp_configurations
|
||||
(status, host, port, security_mode, username, password_ciphertext, password_nonce,
|
||||
secret_key_version, from_address, from_name, created_by_user_id, created_at, updated_at)
|
||||
values ('DRAFT', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
returning id
|
||||
""", Long.class, values);
|
||||
}
|
||||
jdbc.update("""
|
||||
update smtp_configurations
|
||||
set host = ?, port = ?, security_mode = ?, username = ?, password_ciphertext = ?,
|
||||
password_nonce = ?, secret_key_version = ?, from_address = ?, from_name = ?,
|
||||
tested_at = null, tested_by_user_id = null, updated_at = ?, version = version + 1
|
||||
where id = ?
|
||||
""", draft.host().trim(), draft.port(), draft.securityMode().name(), clean(draft.username()),
|
||||
password == null ? null : password.ciphertext(), password == null ? null : password.nonce(),
|
||||
password == null ? null : password.keyVersion(), draft.fromAddress().trim(), draft.fromName().trim(),
|
||||
now, existing);
|
||||
return existing;
|
||||
});
|
||||
}
|
||||
|
||||
private static Object[] values(SmtpDraft draft, SecretCipher.EncryptedSecret password, long adminId,
|
||||
OffsetDateTime now) {
|
||||
return new Object[] {
|
||||
draft.host().trim(), draft.port(), draft.securityMode().name(), clean(draft.username()),
|
||||
password == null ? null : password.ciphertext(), password == null ? null : password.nonce(),
|
||||
password == null ? null : password.keyVersion(), draft.fromAddress().trim(), draft.fromName().trim(),
|
||||
adminId, now, now
|
||||
};
|
||||
}
|
||||
|
||||
public void testDraft(long draftId, long adminId, String recipient) {
|
||||
SmtpConnection connection = load(draftId, "DRAFT");
|
||||
probe.send(connection, recipient, "Lab Timesheet SMTP test", "SMTP configuration test succeeded.");
|
||||
OffsetDateTime now = now();
|
||||
if (jdbc.update("""
|
||||
update smtp_configurations
|
||||
set tested_at = ?, tested_by_user_id = ?, updated_at = ?, version = version + 1
|
||||
where id = ? and status = 'DRAFT'
|
||||
""", now, adminId, now, draftId) != 1) {
|
||||
throw new IllegalStateException("SMTP draft is no longer available");
|
||||
}
|
||||
}
|
||||
|
||||
public void activate(long draftId, long adminId) {
|
||||
transactions.executeWithoutResult(status -> {
|
||||
OffsetDateTime testedAt = jdbc.query("""
|
||||
select tested_at from smtp_configurations where id = ? and status = 'DRAFT' for update
|
||||
""", resultSet -> resultSet.next() ? resultSet.getObject(1, OffsetDateTime.class) : null, draftId);
|
||||
if (testedAt == null) {
|
||||
throw new IllegalStateException("SMTP draft must pass a test before activation");
|
||||
}
|
||||
OffsetDateTime now = now();
|
||||
jdbc.update("""
|
||||
update smtp_configurations
|
||||
set status = 'RETIRED', retired_at = ?, retired_by_user_id = ?, updated_at = ?, version = version + 1
|
||||
where status = 'ACTIVE'
|
||||
""", now, adminId, now);
|
||||
jdbc.update("""
|
||||
update smtp_configurations
|
||||
set status = 'ACTIVE', activated_at = ?, activated_by_user_id = ?, updated_at = ?, version = version + 1
|
||||
where id = ? and status = 'DRAFT'
|
||||
""", now, adminId, now, draftId);
|
||||
});
|
||||
}
|
||||
|
||||
public boolean hasActiveConfiguration() {
|
||||
return jdbc.queryForObject("select exists(select 1 from smtp_configurations where status = 'ACTIVE')",
|
||||
Boolean.class);
|
||||
}
|
||||
|
||||
public SmtpConnection activeConnection() {
|
||||
return jdbc.query("select id from smtp_configurations where status = 'ACTIVE'",
|
||||
resultSet -> {
|
||||
if (!resultSet.next()) {
|
||||
throw new IllegalStateException("Active SMTP configuration is required");
|
||||
}
|
||||
return load(resultSet.getLong(1), "ACTIVE");
|
||||
});
|
||||
}
|
||||
|
||||
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
|
||||
probe.send(activeConnection(), recipient, subject, body);
|
||||
}
|
||||
|
||||
private SmtpConnection load(long id, String requiredStatus) {
|
||||
return jdbc.query("""
|
||||
select host, port, security_mode, username, password_ciphertext, password_nonce,
|
||||
from_address, from_name
|
||||
from smtp_configurations where id = ? and status = ?
|
||||
""", resultSet -> {
|
||||
if (!resultSet.next()) {
|
||||
throw new IllegalStateException("SMTP configuration is not available");
|
||||
}
|
||||
byte[] ciphertext = resultSet.getBytes("password_ciphertext");
|
||||
return new SmtpConnection(
|
||||
resultSet.getString("host"), resultSet.getInt("port"),
|
||||
SecurityMode.valueOf(resultSet.getString("security_mode")),
|
||||
resultSet.getString("username"),
|
||||
ciphertext == null ? null : secrets.decrypt(ciphertext, resultSet.getBytes("password_nonce")),
|
||||
resultSet.getString("from_address"), resultSet.getString("from_name"));
|
||||
}, id, requiredStatus);
|
||||
}
|
||||
|
||||
private void validate(SmtpDraft draft) {
|
||||
if (draft.host() == null || draft.host().isBlank() || draft.port() < 1 || draft.port() > 65535
|
||||
|| draft.securityMode() == null || draft.fromAddress() == null || draft.fromAddress().isBlank()
|
||||
|| draft.fromName() == null || draft.fromName().isBlank()) {
|
||||
throw new IllegalArgumentException("Valid SMTP host, port, security mode, From address and name are required");
|
||||
}
|
||||
if ((clean(draft.username()) == null) != (draft.password() == null || draft.password().isEmpty())) {
|
||||
throw new IllegalArgumentException("SMTP username and password must be supplied together");
|
||||
}
|
||||
if (draft.securityMode() == SecurityMode.NONE
|
||||
&& !environment.acceptsProfiles(Profiles.of("dev", "test"))) {
|
||||
throw new IllegalArgumentException("Plaintext SMTP is allowed only in dev and test");
|
||||
}
|
||||
}
|
||||
|
||||
private OffsetDateTime now() {
|
||||
return OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
public enum SecurityMode {
|
||||
NONE,
|
||||
STARTTLS,
|
||||
TLS
|
||||
}
|
||||
|
||||
public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password,
|
||||
String fromAddress, String fromName) {
|
||||
}
|
||||
|
||||
public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password,
|
||||
String fromAddress, String fromName) {
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.lab.labtimesheet.configuration;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface SmtpProbe {
|
||||
void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body);
|
||||
}
|
||||
+4
-3
@@ -1,17 +1,18 @@
|
||||
package com.lab.labtimesheet.accounts;
|
||||
package com.lab.labtimesheet.feature.account.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.service.BootstrapService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
class BootstrapAccessFilter extends OncePerRequestFilter {
|
||||
public class BootstrapAccessFilter extends OncePerRequestFilter {
|
||||
private final BootstrapService bootstrap;
|
||||
|
||||
BootstrapAccessFilter(BootstrapService bootstrap) {
|
||||
public BootstrapAccessFilter(BootstrapService bootstrap) {
|
||||
this.bootstrap = bootstrap;
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
package com.lab.labtimesheet.accounts;
|
||||
package com.lab.labtimesheet.feature.account.controller;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.service.BootstrapService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet.accounts;
|
||||
package com.lab.labtimesheet.feature.account.controller;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.lab.labtimesheet.feature.account.model;
|
||||
|
||||
public enum AccountStatus {
|
||||
PENDING_ACTIVATION,
|
||||
ACTIVE,
|
||||
LOCKED,
|
||||
DEACTIVATED
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.lab.labtimesheet.feature.account.model;
|
||||
|
||||
public enum GlobalRole {
|
||||
ADMIN,
|
||||
MENTOR,
|
||||
INTERN
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.lab.labtimesheet.feature.account.model;
|
||||
|
||||
public enum InternshipStatus {
|
||||
NOT_STARTED,
|
||||
ACTIVE,
|
||||
COMPLETED,
|
||||
WITHDRAWN
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.lab.labtimesheet.feature.account.model.dto;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
|
||||
public record AccountIdentity(
|
||||
long id,
|
||||
String email,
|
||||
String displayName,
|
||||
GlobalRole role,
|
||||
AccountStatus status) {
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.lab.labtimesheet.feature.account.model.entity;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
@Entity
|
||||
@Table(name = "app_users")
|
||||
public class AppUser {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 320)
|
||||
private String email;
|
||||
|
||||
@Column(name = "display_name", nullable = false, length = 120)
|
||||
private String displayName;
|
||||
|
||||
@Column(name = "password_hash", length = 255)
|
||||
private String passwordHash;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "global_role", nullable = false, length = 16, updatable = false)
|
||||
private GlobalRole globalRole;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "account_status", nullable = false, length = 32)
|
||||
private AccountStatus accountStatus;
|
||||
|
||||
@Column(name = "activated_at")
|
||||
private Instant activatedAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "created_by_user_id")
|
||||
private AppUser createdBy;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
protected AppUser() {
|
||||
}
|
||||
|
||||
private AppUser(String email, String displayName, String passwordHash, GlobalRole globalRole,
|
||||
AccountStatus accountStatus, Instant activatedAt, AppUser createdBy, Instant now) {
|
||||
this.email = email;
|
||||
this.displayName = displayName;
|
||||
this.passwordHash = passwordHash;
|
||||
this.globalRole = globalRole;
|
||||
this.accountStatus = accountStatus;
|
||||
this.activatedAt = activatedAt;
|
||||
this.createdBy = createdBy;
|
||||
this.createdAt = now;
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
public static AppUser bootstrapAdmin(String email, String displayName, String passwordHash, Instant now) {
|
||||
return new AppUser(email, displayName, passwordHash, GlobalRole.ADMIN, AccountStatus.ACTIVE, now, null, now);
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public GlobalRole getGlobalRole() {
|
||||
return globalRole;
|
||||
}
|
||||
|
||||
public AccountStatus getAccountStatus() {
|
||||
return accountStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.lab.labtimesheet.feature.account.model.entity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
@Entity
|
||||
@Table(name = "intern_profiles")
|
||||
public class InternProfile {
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Column(name = "student_code", nullable = false, length = 64)
|
||||
private String studentCode;
|
||||
|
||||
@Column(length = 120)
|
||||
private String department;
|
||||
|
||||
@Column(length = 32)
|
||||
private String phone;
|
||||
|
||||
@Column(name = "internship_start_date", nullable = false)
|
||||
private LocalDate internshipStartDate;
|
||||
|
||||
@Column(name = "internship_end_date", nullable = false)
|
||||
private LocalDate internshipEndDate;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "internship_status", nullable = false, length = 24)
|
||||
private InternshipStatus internshipStatus;
|
||||
|
||||
@Column(name = "activated_at")
|
||||
private Instant activatedAt;
|
||||
|
||||
@Column(name = "completed_at")
|
||||
private Instant completedAt;
|
||||
|
||||
@Column(name = "withdrawn_at")
|
||||
private Instant withdrawnAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
protected InternProfile() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.lab.labtimesheet.feature.account.model.entity;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
@Entity
|
||||
@Table(name = "system_state")
|
||||
public class SystemState {
|
||||
@Id
|
||||
@Column(name = "singleton_id")
|
||||
private short singletonId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean initialized;
|
||||
|
||||
@Column(name = "initialized_at")
|
||||
private Instant initializedAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "bootstrap_admin_id")
|
||||
private AppUser bootstrapAdmin;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
protected SystemState() {
|
||||
}
|
||||
|
||||
public boolean isInitialized() {
|
||||
return initialized;
|
||||
}
|
||||
|
||||
public void initialize(AppUser admin, Instant now) {
|
||||
if (initialized) {
|
||||
throw new IllegalStateException("Bootstrap is already complete");
|
||||
}
|
||||
initialized = true;
|
||||
initializedAt = now;
|
||||
bootstrapAdmin = admin;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.lab.labtimesheet.feature.account.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
|
||||
@Query("select u from AppUser u where lower(trim(u.email)) = :email")
|
||||
Optional<AppUser> findByNormalizedEmail(@Param("email") String email);
|
||||
|
||||
long countByGlobalRoleAndAccountStatus(GlobalRole role, AccountStatus status);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.lab.labtimesheet.feature.account.repository;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.InternProfile;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface InternProfileRepository extends JpaRepository<InternProfile, Long> {
|
||||
boolean existsByUserIdAndInternshipStatus(Long userId, InternshipStatus status);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.lab.labtimesheet.feature.account.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
public interface SystemStateRepository extends JpaRepository<SystemState, Short> {
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select s from SystemState s where s.singletonId = 1")
|
||||
Optional<SystemState> findSingletonForUpdate();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.lab.labtimesheet.feature.account.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.model.InternshipStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.dto.AccountIdentity;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.InternProfileRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class AccountService {
|
||||
private final AppUserRepository users;
|
||||
private final InternProfileRepository internProfiles;
|
||||
|
||||
AccountService(AppUserRepository users, InternProfileRepository internProfiles) {
|
||||
this.users = users;
|
||||
this.internProfiles = internProfiles;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AccountIdentity requireIdentityById(long userId) {
|
||||
return users.findById(userId).map(AccountService::identity)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AccountIdentity requireIdentityByEmail(String email) {
|
||||
return users.findByNormalizedEmail(BootstrapService.normalizeEmail(email)).map(AccountService::identity)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Account not found"));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isEligibleIntern(long userId) {
|
||||
return users.findById(userId)
|
||||
.filter(user -> user.getGlobalRole() == GlobalRole.INTERN)
|
||||
.filter(user -> user.getAccountStatus() == AccountStatus.ACTIVE)
|
||||
.filter(user -> internProfiles.existsByUserIdAndInternshipStatus(
|
||||
user.getId(), InternshipStatus.ACTIVE))
|
||||
.isPresent();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public AccountIdentity requireEligibleIntern(long userId) {
|
||||
if (!isEligibleIntern(userId)) {
|
||||
throw new IllegalArgumentException("An active Intern account and internship are required");
|
||||
}
|
||||
return requireIdentityById(userId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long requireActiveAdminId(String email) {
|
||||
AppUser user = users.findByNormalizedEmail(BootstrapService.normalizeEmail(email))
|
||||
.orElseThrow(() -> new IllegalStateException("Authenticated Admin is missing"));
|
||||
return requireActiveAdmin(user);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long requireActiveAdminId(long userId) {
|
||||
AppUser user = users.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Admin not found"));
|
||||
return requireActiveAdmin(user);
|
||||
}
|
||||
|
||||
private static long requireActiveAdmin(AppUser user) {
|
||||
if (user.getGlobalRole() != GlobalRole.ADMIN || user.getAccountStatus() != AccountStatus.ACTIVE) {
|
||||
throw new IllegalArgumentException("An active Admin is required");
|
||||
}
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
private static AccountIdentity identity(AppUser user) {
|
||||
return new AccountIdentity(
|
||||
user.getId(), user.getEmail(), user.getDisplayName(), user.getGlobalRole(), user.getAccountStatus());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.lab.labtimesheet.feature.account.service;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Locale;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.entity.AppUser;
|
||||
import com.lab.labtimesheet.feature.account.model.entity.SystemState;
|
||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class BootstrapService {
|
||||
private final SystemStateRepository systemStates;
|
||||
private final AppUserRepository users;
|
||||
private final PasswordEncoder passwords;
|
||||
private final Clock clock;
|
||||
|
||||
BootstrapService(SystemStateRepository systemStates, AppUserRepository users, PasswordEncoder passwords,
|
||||
Clock clock) {
|
||||
this.systemStates = systemStates;
|
||||
this.users = users;
|
||||
this.passwords = passwords;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BootstrapOutcome bootstrap(String email, String displayName, String password) {
|
||||
String normalizedEmail = normalizeEmail(email);
|
||||
String normalizedName = requireText(displayName, "Display name");
|
||||
requirePassword(password);
|
||||
|
||||
SystemState state = systemStates.findSingletonForUpdate()
|
||||
.orElseThrow(() -> new IllegalStateException("System state is missing"));
|
||||
if (state.isInitialized()) {
|
||||
return BootstrapOutcome.ALREADY_INITIALIZED;
|
||||
}
|
||||
var now = clock.instant();
|
||||
AppUser admin = users.save(AppUser.bootstrapAdmin(
|
||||
normalizedEmail, normalizedName, passwords.encode(password), now));
|
||||
state.initialize(admin, now);
|
||||
return BootstrapOutcome.CREATED;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isInitialized() {
|
||||
return systemStates.findById((short) 1).map(SystemState::isInitialized).orElse(false);
|
||||
}
|
||||
|
||||
public static String normalizeEmail(String email) {
|
||||
return requireText(email, "Email").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public static void requirePassword(String password) {
|
||||
if (password == null || password.length() < 12 || password.length() > 128) {
|
||||
throw new IllegalArgumentException("Password must contain 12 through 128 characters");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireText(String value, String field) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(field + " is required");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
public enum BootstrapOutcome {
|
||||
CREATED,
|
||||
ALREADY_INITIALIZED
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.lab.labtimesheet.feature.account.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
class DatabaseUserDetailsService implements UserDetailsService {
|
||||
private final AppUserRepository users;
|
||||
|
||||
DatabaseUserDetailsService(AppUserRepository users) {
|
||||
this.users = users;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
var account = users.findByNormalizedEmail(BootstrapService.normalizeEmail(username))
|
||||
.orElseThrow(() -> new UsernameNotFoundException("Invalid credentials"));
|
||||
String hash = account.getPasswordHash();
|
||||
return User.withUsername(account.getEmail())
|
||||
.password(hash == null ? "{noop}unavailable" : hash)
|
||||
.roles(account.getGlobalRole().name())
|
||||
.disabled(account.getAccountStatus() != AccountStatus.ACTIVE)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -1,10 +1,11 @@
|
||||
package com.lab.labtimesheet.configuration;
|
||||
package com.lab.labtimesheet.feature.integration.controller;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode;
|
||||
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||
import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -15,11 +16,11 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
@RequestMapping("/admin/smtp")
|
||||
class SmtpController {
|
||||
private final SmtpConfigurationService smtp;
|
||||
private final JdbcTemplate jdbc;
|
||||
private final AccountService accounts;
|
||||
|
||||
SmtpController(SmtpConfigurationService smtp, JdbcTemplate jdbc) {
|
||||
SmtpController(SmtpConfigurationService smtp, AccountService accounts) {
|
||||
this.smtp = smtp;
|
||||
this.jdbc = jdbc;
|
||||
this.accounts = accounts;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -49,7 +50,6 @@ class SmtpController {
|
||||
}
|
||||
|
||||
private long adminId(Principal principal) {
|
||||
return jdbc.queryForObject("select id from app_users where lower(btrim(email)) = lower(btrim(?))", Long.class,
|
||||
principal.getName());
|
||||
return accounts.requireActiveAdminId(principal.getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.lab.labtimesheet.feature.integration.model;
|
||||
|
||||
public enum SecurityMode {
|
||||
NONE,
|
||||
STARTTLS,
|
||||
TLS
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.lab.labtimesheet.feature.integration.model;
|
||||
|
||||
public enum SmtpStatus {
|
||||
DRAFT,
|
||||
ACTIVE,
|
||||
RETIRED
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.lab.labtimesheet.feature.integration.model.dto;
|
||||
|
||||
public record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) {
|
||||
public EncryptedSecret {
|
||||
ciphertext = ciphertext.clone();
|
||||
nonce = nonce.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] ciphertext() {
|
||||
return ciphertext.clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] nonce() {
|
||||
return nonce.clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.lab.labtimesheet.feature.integration.model.dto;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
|
||||
public record SmtpConnection(String host, int port, SecurityMode securityMode, String username, String password,
|
||||
String fromAddress, String fromName) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.lab.labtimesheet.feature.integration.model.dto;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
|
||||
public record SmtpDraft(String host, int port, SecurityMode securityMode, String username, String password,
|
||||
String fromAddress, String fromName) {
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package com.lab.labtimesheet.feature.integration.model.entity;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
@Entity
|
||||
@Table(name = "smtp_configurations")
|
||||
public class SmtpConfiguration {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
private SmtpStatus status;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String host;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int port;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "security_mode", nullable = false, length = 16)
|
||||
private SecurityMode securityMode;
|
||||
|
||||
@Column(length = 320)
|
||||
private String username;
|
||||
|
||||
@Column(name = "password_ciphertext")
|
||||
private byte[] passwordCiphertext;
|
||||
|
||||
@Column(name = "password_nonce")
|
||||
private byte[] passwordNonce;
|
||||
|
||||
@Column(name = "secret_key_version")
|
||||
private Integer secretKeyVersion;
|
||||
|
||||
@Column(name = "from_address", nullable = false, length = 320)
|
||||
private String fromAddress;
|
||||
|
||||
@Column(name = "from_name", nullable = false, length = 120)
|
||||
private String fromName;
|
||||
|
||||
@Column(name = "tested_at")
|
||||
private Instant testedAt;
|
||||
|
||||
@Column(name = "tested_by_user_id")
|
||||
private Long testedByUserId;
|
||||
|
||||
@Column(name = "activated_at")
|
||||
private Instant activatedAt;
|
||||
|
||||
@Column(name = "activated_by_user_id")
|
||||
private Long activatedByUserId;
|
||||
|
||||
@Column(name = "retired_at")
|
||||
private Instant retiredAt;
|
||||
|
||||
@Column(name = "retired_by_user_id")
|
||||
private Long retiredByUserId;
|
||||
|
||||
@Column(name = "created_by_user_id", nullable = false)
|
||||
private Long createdByUserId;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@Version
|
||||
private long version;
|
||||
|
||||
protected SmtpConfiguration() {
|
||||
}
|
||||
|
||||
public static SmtpConfiguration draft(SmtpDraft draft, EncryptedSecret password, long adminId, Instant now) {
|
||||
SmtpConfiguration configuration = new SmtpConfiguration();
|
||||
configuration.status = SmtpStatus.DRAFT;
|
||||
configuration.createdByUserId = adminId;
|
||||
configuration.createdAt = now;
|
||||
configuration.updateDraft(draft, password, now);
|
||||
return configuration;
|
||||
}
|
||||
|
||||
public void updateDraft(SmtpDraft draft, EncryptedSecret password, Instant now) {
|
||||
if (status != SmtpStatus.DRAFT) {
|
||||
throw new IllegalStateException("Only an SMTP draft can be edited");
|
||||
}
|
||||
host = draft.host().trim();
|
||||
port = draft.port();
|
||||
securityMode = draft.securityMode();
|
||||
username = clean(draft.username());
|
||||
passwordCiphertext = password == null ? null : password.ciphertext();
|
||||
passwordNonce = password == null ? null : password.nonce();
|
||||
secretKeyVersion = password == null ? null : password.keyVersion();
|
||||
fromAddress = draft.fromAddress().trim();
|
||||
fromName = draft.fromName().trim();
|
||||
testedAt = null;
|
||||
testedByUserId = null;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
public void markTested(long adminId, Instant now) {
|
||||
if (status != SmtpStatus.DRAFT) {
|
||||
throw new IllegalStateException("SMTP draft is no longer available");
|
||||
}
|
||||
testedAt = now;
|
||||
testedByUserId = adminId;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
public void activate(long adminId, Instant now) {
|
||||
if (status != SmtpStatus.DRAFT || testedAt == null) {
|
||||
throw new IllegalStateException("SMTP draft must pass a test before activation");
|
||||
}
|
||||
status = SmtpStatus.ACTIVE;
|
||||
activatedAt = now;
|
||||
activatedByUserId = adminId;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
public void retire(long adminId, Instant now) {
|
||||
if (status != SmtpStatus.ACTIVE) {
|
||||
throw new IllegalStateException("Only active SMTP can be retired");
|
||||
}
|
||||
status = SmtpStatus.RETIRED;
|
||||
retiredAt = now;
|
||||
retiredByUserId = adminId;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public SmtpStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getHost() {
|
||||
return host;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public SecurityMode getSecurityMode() {
|
||||
return securityMode;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public byte[] getPasswordCiphertext() {
|
||||
return passwordCiphertext == null ? null : passwordCiphertext.clone();
|
||||
}
|
||||
|
||||
public byte[] getPasswordNonce() {
|
||||
return passwordNonce == null ? null : passwordNonce.clone();
|
||||
}
|
||||
|
||||
public Integer getSecretKeyVersion() {
|
||||
return secretKeyVersion;
|
||||
}
|
||||
|
||||
public String getFromAddress() {
|
||||
return fromAddress;
|
||||
}
|
||||
|
||||
public String getFromName() {
|
||||
return fromName;
|
||||
}
|
||||
|
||||
public Instant getTestedAt() {
|
||||
return testedAt;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.lab.labtimesheet.feature.integration.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
|
||||
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
|
||||
public interface SmtpConfigurationRepository extends JpaRepository<SmtpConfiguration, Long> {
|
||||
Optional<SmtpConfiguration> findByStatus(SmtpStatus status);
|
||||
|
||||
boolean existsByStatus(SmtpStatus status);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
Optional<SmtpConfiguration> findWithLockByIdAndStatus(Long id, SmtpStatus status);
|
||||
}
|
||||
+6
-6
@@ -1,29 +1,29 @@
|
||||
package com.lab.labtimesheet.configuration;
|
||||
package com.lab.labtimesheet.feature.integration.service;
|
||||
|
||||
import java.util.Properties;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
class JavaMailSmtpProbe implements SmtpProbe {
|
||||
|
||||
@Override
|
||||
public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject, String body) {
|
||||
public void send(SmtpConnection connection, String recipient, String subject, String body) {
|
||||
JavaMailSenderImpl sender = new JavaMailSenderImpl();
|
||||
sender.setHost(connection.host());
|
||||
sender.setPort(connection.port());
|
||||
sender.setUsername(connection.username());
|
||||
sender.setPassword(connection.password());
|
||||
Properties properties = sender.getJavaMailProperties();
|
||||
if (connection.securityMode() == SmtpConfigurationService.SecurityMode.STARTTLS) {
|
||||
if (connection.securityMode() == SecurityMode.STARTTLS) {
|
||||
properties.setProperty("mail.smtp.starttls.enable", "true");
|
||||
properties.setProperty("mail.smtp.starttls.required", "true");
|
||||
} else if (connection.securityMode() == SmtpConfigurationService.SecurityMode.TLS) {
|
||||
} else if (connection.securityMode() == SecurityMode.TLS) {
|
||||
sender.setProtocol("smtps");
|
||||
}
|
||||
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(connection.fromAddress());
|
||||
message.setTo(recipient);
|
||||
+4
-6
@@ -1,13 +1,14 @@
|
||||
package com.lab.labtimesheet.configuration;
|
||||
package com.lab.labtimesheet.feature.integration.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
import com.lab.labtimesheet.config.SecurityProperties;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@@ -19,7 +20,7 @@ public class SecretCipher {
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
SecretCipher(SecurityProperties properties) {
|
||||
this.key = new SecretKeySpec(properties.decodedMasterKey(), "AES");
|
||||
key = new SecretKeySpec(properties.decodedMasterKey(), "AES");
|
||||
}
|
||||
|
||||
EncryptedSecret encrypt(String plaintext) {
|
||||
@@ -43,7 +44,4 @@ public class SecretCipher {
|
||||
throw new IllegalStateException("Unable to decrypt integration secret", exception);
|
||||
}
|
||||
}
|
||||
|
||||
record EncryptedSecret(byte[] ciphertext, byte[] nonce, int keyVersion) {
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package com.lab.labtimesheet.feature.integration.service;
|
||||
|
||||
import java.time.Clock;
|
||||
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.EncryptedSecret;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||
import com.lab.labtimesheet.feature.integration.model.entity.SmtpConfiguration;
|
||||
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class SmtpConfigurationService {
|
||||
private final SmtpConfigurationRepository configurations;
|
||||
private final AccountService accounts;
|
||||
private final SecretCipher secrets;
|
||||
private final SmtpProbe probe;
|
||||
private final Environment environment;
|
||||
private final Clock clock;
|
||||
|
||||
SmtpConfigurationService(SmtpConfigurationRepository configurations, AccountService accounts,
|
||||
SecretCipher secrets, SmtpProbe probe, Environment environment, Clock clock) {
|
||||
this.configurations = configurations;
|
||||
this.accounts = accounts;
|
||||
this.secrets = secrets;
|
||||
this.probe = probe;
|
||||
this.environment = environment;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public long saveDraft(long adminId, SmtpDraft draft) {
|
||||
validate(draft);
|
||||
EncryptedSecret password = draft.password() == null ? null : secrets.encrypt(draft.password());
|
||||
var now = clock.instant();
|
||||
long verifiedAdminId = accounts.requireActiveAdminId(adminId);
|
||||
SmtpConfiguration configuration = configurations.findByStatus(SmtpStatus.DRAFT)
|
||||
.map(existing -> {
|
||||
existing.updateDraft(draft, password, now);
|
||||
return existing;
|
||||
})
|
||||
.orElseGet(() -> SmtpConfiguration.draft(draft, password, verifiedAdminId, now));
|
||||
return configurations.save(configuration).getId();
|
||||
}
|
||||
|
||||
public void testDraft(long draftId, long adminId, String recipient) {
|
||||
SmtpConfiguration draft = configurations.findById(draftId)
|
||||
.filter(configuration -> configuration.getStatus() == SmtpStatus.DRAFT)
|
||||
.orElseThrow(() -> new IllegalStateException("SMTP configuration is not available"));
|
||||
probe.send(connection(draft), recipient, "Lab Timesheet SMTP test", "SMTP configuration test succeeded.");
|
||||
long verifiedAdminId = accounts.requireActiveAdminId(adminId);
|
||||
draft.markTested(verifiedAdminId, clock.instant());
|
||||
configurations.save(draft);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void activate(long draftId, long adminId) {
|
||||
SmtpConfiguration draft = configurations.findWithLockByIdAndStatus(draftId, SmtpStatus.DRAFT)
|
||||
.orElseThrow(() -> new IllegalStateException("SMTP draft must pass a test before activation"));
|
||||
long verifiedAdminId = accounts.requireActiveAdminId(adminId);
|
||||
var now = clock.instant();
|
||||
configurations.findByStatus(SmtpStatus.ACTIVE)
|
||||
.ifPresent(active -> active.retire(verifiedAdminId, now));
|
||||
draft.activate(verifiedAdminId, now);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean hasActiveConfiguration() {
|
||||
return configurations.existsByStatus(SmtpStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SmtpConnection activeConnection() {
|
||||
return configurations.findByStatus(SmtpStatus.ACTIVE)
|
||||
.map(this::connection)
|
||||
.orElseThrow(() -> new IllegalStateException("Active SMTP configuration is required"));
|
||||
}
|
||||
|
||||
public void sendWithActiveConfiguration(String recipient, String subject, String body) {
|
||||
probe.send(activeConnection(), recipient, subject, body);
|
||||
}
|
||||
|
||||
private SmtpConnection connection(SmtpConfiguration configuration) {
|
||||
byte[] ciphertext = configuration.getPasswordCiphertext();
|
||||
return new SmtpConnection(
|
||||
configuration.getHost(), configuration.getPort(), configuration.getSecurityMode(),
|
||||
configuration.getUsername(),
|
||||
ciphertext == null ? null : secrets.decrypt(ciphertext, configuration.getPasswordNonce()),
|
||||
configuration.getFromAddress(), configuration.getFromName());
|
||||
}
|
||||
|
||||
private void validate(SmtpDraft draft) {
|
||||
if (draft.host() == null || draft.host().isBlank() || draft.port() < 1 || draft.port() > 65535
|
||||
|| draft.securityMode() == null || draft.fromAddress() == null || draft.fromAddress().isBlank()
|
||||
|| draft.fromName() == null || draft.fromName().isBlank()) {
|
||||
throw new IllegalArgumentException("Valid SMTP host, port, security mode, From address and name are required");
|
||||
}
|
||||
if ((clean(draft.username()) == null) != (draft.password() == null || draft.password().isEmpty())) {
|
||||
throw new IllegalArgumentException("SMTP username and password must be supplied together");
|
||||
}
|
||||
if (draft.securityMode() == SecurityMode.NONE
|
||||
&& !environment.acceptsProfiles(Profiles.of("dev", "test"))) {
|
||||
throw new IllegalArgumentException("Plaintext SMTP is allowed only in dev and test");
|
||||
}
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.lab.labtimesheet.feature.integration.service;
|
||||
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface SmtpProbe {
|
||||
void send(SmtpConnection connection, String recipient, String subject, String body);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.lab.labtimesheet.notifications;
|
||||
|
||||
/** Notifications module boundary. */
|
||||
public final class ModuleBoundary {
|
||||
private ModuleBoundary() {
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.lab.labtimesheet.projects;
|
||||
|
||||
/** Projects and tasks module boundary. */
|
||||
public final class ModuleBoundary {
|
||||
private ModuleBoundary() {
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.lab.labtimesheet.reporting;
|
||||
|
||||
/** Reporting module boundary. */
|
||||
public final class ModuleBoundary {
|
||||
private ModuleBoundary() {
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.lab.labtimesheet;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
abstract class PlatformDatabaseTestSupport {
|
||||
|
||||
@Autowired
|
||||
protected JdbcTemplate jdbc;
|
||||
|
||||
@BeforeEach
|
||||
void resetPlatformData() {
|
||||
jdbc.execute("TRUNCATE smtp_configurations, user_action_tokens, intern_profiles, app_users RESTART IDENTITY CASCADE");
|
||||
jdbc.update("insert into system_state (singleton_id) values (1)");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.lab.labtimesheet;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
|
||||
public class TestLabtimesheetApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.lab.labtimesheet.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.lab.labtimesheet.LabtimesheetApplication;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class LayerStructureTest {
|
||||
private static final Path BASE_PACKAGE = Path.of("src/main/java/com/lab/labtimesheet");
|
||||
private static final Set<String> APPROVED_ROOT_PACKAGES = Set.of("config", "feature");
|
||||
private static final Set<String> APPROVED_FEATURES = Set.of(
|
||||
"account", "integration", "project", "task", "attendance", "notification", "reporting");
|
||||
private static final Set<String> APPROVED_FEATURE_PACKAGES = Set.of(
|
||||
"controller", "exception", "model", "model/dto", "model/entity", "repository", "service");
|
||||
private static final Pattern INTERNAL_IMPORT = Pattern.compile(
|
||||
"import com\\.lab\\.labtimesheet\\.feature\\.([^.]+)\\.(?:repository|model\\.entity)\\.");
|
||||
|
||||
@Test
|
||||
void applicationUsesOnlyApprovedPackageByFeatureStructure() throws IOException {
|
||||
assertThat(LabtimesheetApplication.class.getPackageName()).isEqualTo("com.lab.labtimesheet");
|
||||
|
||||
try (var entries = Files.list(BASE_PACKAGE)) {
|
||||
Set<String> directories = entries
|
||||
.filter(Files::isDirectory)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertThat(directories).containsExactlyInAnyOrderElementsOf(APPROVED_ROOT_PACKAGES);
|
||||
}
|
||||
|
||||
Path featurePackage = BASE_PACKAGE.resolve("feature");
|
||||
try (var entries = Files.list(featurePackage)) {
|
||||
Set<String> features = entries
|
||||
.filter(Files::isDirectory)
|
||||
.map(path -> path.getFileName().toString())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertThat(features).isNotEmpty().isSubsetOf(APPROVED_FEATURES);
|
||||
}
|
||||
|
||||
try (var entries = Files.walk(featurePackage)) {
|
||||
List<String> featurePackages = entries
|
||||
.filter(Files::isDirectory)
|
||||
.filter(path -> path.getNameCount() > featurePackage.getNameCount() + 1)
|
||||
.map(path -> path.subpath(featurePackage.getNameCount() + 1, path.getNameCount()).toString())
|
||||
.toList();
|
||||
|
||||
assertThat(featurePackages).allMatch(APPROVED_FEATURE_PACKAGES::contains);
|
||||
}
|
||||
|
||||
try (var entries = Files.walk(featurePackage)) {
|
||||
List<String> crossFeaturePersistenceImports = entries
|
||||
.filter(path -> path.toString().endsWith(".java"))
|
||||
.flatMap(path -> persistenceImportsFromAnotherFeature(featurePackage, path).stream())
|
||||
.toList();
|
||||
|
||||
assertThat(crossFeaturePersistenceImports).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> persistenceImportsFromAnotherFeature(Path featurePackage, Path source) {
|
||||
String owningFeature = featurePackage.relativize(source).getName(0).toString();
|
||||
try {
|
||||
return Files.readAllLines(source).stream()
|
||||
.filter(line -> {
|
||||
var matcher = INTERNAL_IMPORT.matcher(line);
|
||||
return matcher.find() && !matcher.group(1).equals(owningFeature);
|
||||
})
|
||||
.map(line -> source + ": " + line.trim())
|
||||
.toList();
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Cannot inspect " + source, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-16
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet;
|
||||
package com.lab.labtimesheet.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -11,7 +11,6 @@ import javax.sql.DataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
@@ -21,26 +20,12 @@ import org.springframework.test.context.ActiveProfiles;
|
||||
@ActiveProfiles("test")
|
||||
class PlatformFoundationTest {
|
||||
|
||||
@Autowired
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Autowired
|
||||
private Clock clock;
|
||||
|
||||
@Test
|
||||
void applicationExposesRequiredModulePackages() throws ClassNotFoundException {
|
||||
assertThat(applicationContext).isNotNull();
|
||||
assertThat(Class.forName("com.lab.labtimesheet.accounts.ModuleBoundary")).isNotNull();
|
||||
assertThat(Class.forName("com.lab.labtimesheet.configuration.ModuleBoundary")).isNotNull();
|
||||
assertThat(Class.forName("com.lab.labtimesheet.projects.ModuleBoundary")).isNotNull();
|
||||
assertThat(Class.forName("com.lab.labtimesheet.attendance.ModuleBoundary")).isNotNull();
|
||||
assertThat(Class.forName("com.lab.labtimesheet.notifications.ModuleBoundary")).isNotNull();
|
||||
assertThat(Class.forName("com.lab.labtimesheet.reporting.ModuleBoundary")).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void flywayCreatesApprovedPostgresCatalog() {
|
||||
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet;
|
||||
package com.lab.labtimesheet.config;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
@@ -12,7 +12,7 @@ import org.testcontainers.postgresql.PostgreSQLContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
class TestcontainersConfiguration {
|
||||
public class TestcontainersConfiguration {
|
||||
|
||||
@Bean
|
||||
@ServiceConnection
|
||||
+32
-13
@@ -1,4 +1,4 @@
|
||||
package com.lab.labtimesheet;
|
||||
package com.lab.labtimesheet.feature.account.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
@@ -10,28 +10,42 @@ import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import com.lab.labtimesheet.accounts.BootstrapService;
|
||||
import com.lab.labtimesheet.accounts.BootstrapService.BootstrapOutcome;
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
import com.lab.labtimesheet.feature.account.model.AccountStatus;
|
||||
import com.lab.labtimesheet.feature.account.model.GlobalRole;
|
||||
import com.lab.labtimesheet.feature.account.repository.AppUserRepository;
|
||||
import com.lab.labtimesheet.feature.account.repository.SystemStateRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class BootstrapIntegrationTest extends PlatformDatabaseTestSupport {
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class BootstrapIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private BootstrapService bootstrapService;
|
||||
|
||||
@Autowired
|
||||
private AccountService accountService;
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private AppUserRepository users;
|
||||
|
||||
@Autowired
|
||||
private SystemStateRepository systemStates;
|
||||
|
||||
@Test
|
||||
void onlyBootstrapAndHealthAreAvailableBeforeInitialization() throws Exception {
|
||||
mockMvc.perform(get("/bootstrap")).andExpect(status().isOk());
|
||||
@@ -46,7 +60,7 @@ class BootstrapIntegrationTest extends PlatformDatabaseTestSupport {
|
||||
void concurrentBootstrapCreatesExactlyOneAdminAndPermanentlyCloses() throws Exception {
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<BootstrapOutcome>> futures = new ArrayList<>();
|
||||
List<Future<BootstrapService.BootstrapOutcome>> futures = new ArrayList<>();
|
||||
|
||||
try (var executor = Executors.newFixedThreadPool(2)) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
@@ -63,15 +77,20 @@ class BootstrapIntegrationTest extends PlatformDatabaseTestSupport {
|
||||
}
|
||||
|
||||
assertThat(futures).extracting(future -> future.get()).containsExactlyInAnyOrder(
|
||||
BootstrapOutcome.CREATED, BootstrapOutcome.ALREADY_INITIALIZED);
|
||||
assertThat(jdbc.queryForObject("select count(*) from app_users", Integer.class)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject(
|
||||
"select count(*) from app_users where global_role = 'ADMIN' and account_status = 'ACTIVE'",
|
||||
Integer.class)).isEqualTo(1);
|
||||
BootstrapService.BootstrapOutcome.CREATED, BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
|
||||
assertThat(users.count()).isEqualTo(1);
|
||||
assertThat(users.countByGlobalRoleAndAccountStatus(GlobalRole.ADMIN, AccountStatus.ACTIVE)).isEqualTo(1);
|
||||
var createdUser = users.findAll().getFirst();
|
||||
var identityByEmail = accountService.requireIdentityByEmail(" " + createdUser.getEmail().toUpperCase() + " ");
|
||||
assertThat(identityByEmail.email()).isEqualTo(createdUser.getEmail());
|
||||
assertThat(identityByEmail.displayName()).isEqualTo("First Admin");
|
||||
assertThat(identityByEmail.role()).isEqualTo(GlobalRole.ADMIN);
|
||||
assertThat(identityByEmail.status()).isEqualTo(AccountStatus.ACTIVE);
|
||||
assertThat(accountService.requireIdentityById(identityByEmail.id())).isEqualTo(identityByEmail);
|
||||
assertThat(accountService.isEligibleIntern(identityByEmail.id())).isFalse();
|
||||
assertThat(bootstrapService.bootstrap(
|
||||
"another@example.com", "Another", "correct horse battery staple"))
|
||||
.isEqualTo(BootstrapOutcome.ALREADY_INITIALIZED);
|
||||
assertThat(jdbc.queryForObject("select initialized from system_state where singleton_id = 1", Boolean.class))
|
||||
.isTrue();
|
||||
.isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
|
||||
assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue();
|
||||
}
|
||||
}
|
||||
+26
-21
@@ -1,14 +1,18 @@
|
||||
package com.lab.labtimesheet;
|
||||
package com.lab.labtimesheet.feature.integration.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import com.lab.labtimesheet.accounts.BootstrapService;
|
||||
import com.lab.labtimesheet.configuration.SmtpConfigurationService;
|
||||
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SecurityMode;
|
||||
import com.lab.labtimesheet.configuration.SmtpConfigurationService.SmtpDraft;
|
||||
import com.lab.labtimesheet.configuration.SmtpProbe;
|
||||
|
||||
import com.lab.labtimesheet.config.TestcontainersConfiguration;
|
||||
import com.lab.labtimesheet.feature.account.service.AccountService;
|
||||
import com.lab.labtimesheet.feature.account.service.BootstrapService;
|
||||
import com.lab.labtimesheet.feature.integration.model.SecurityMode;
|
||||
import com.lab.labtimesheet.feature.integration.model.SmtpStatus;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpConnection;
|
||||
import com.lab.labtimesheet.feature.integration.model.dto.SmtpDraft;
|
||||
import com.lab.labtimesheet.feature.integration.repository.SmtpConfigurationRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -21,46 +25,47 @@ import org.springframework.test.context.ActiveProfiles;
|
||||
@Import({TestcontainersConfiguration.class, SmtpIntegrationTest.MailProbeConfiguration.class})
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class SmtpIntegrationTest extends PlatformDatabaseTestSupport {
|
||||
class SmtpIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private BootstrapService bootstrapService;
|
||||
|
||||
@Autowired
|
||||
private AccountService accountService;
|
||||
|
||||
@Autowired
|
||||
private SmtpConfigurationService smtpService;
|
||||
|
||||
@Autowired
|
||||
private RecordingSmtpProbe smtpProbe;
|
||||
|
||||
@Autowired
|
||||
private SmtpConfigurationRepository configurations;
|
||||
|
||||
@Test
|
||||
void failedSmtpTestNeverActivatesDraftAndSecretsRemainEncrypted() {
|
||||
bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||
long adminId = jdbc.queryForObject("select id from app_users", Long.class);
|
||||
long adminId = accountService.requireActiveAdminId("admin@example.com");
|
||||
long draftId = smtpService.saveDraft(adminId, new SmtpDraft(
|
||||
"mailpit", 1025, SecurityMode.NONE, "smtp-user", "smtp-password", "admin@example.com", "Lab"));
|
||||
|
||||
byte[] ciphertext = jdbc.queryForObject(
|
||||
"select password_ciphertext from smtp_configurations where id = ?", byte[].class, draftId);
|
||||
var savedDraft = configurations.findById(draftId).orElseThrow();
|
||||
byte[] ciphertext = savedDraft.getPasswordCiphertext();
|
||||
assertThat(new String(ciphertext, StandardCharsets.ISO_8859_1)).doesNotContain("smtp-password");
|
||||
assertThat(jdbc.queryForObject("select octet_length(password_nonce) from smtp_configurations where id = ?",
|
||||
Integer.class, draftId)).isEqualTo(12);
|
||||
assertThat(jdbc.queryForObject("select secret_key_version from smtp_configurations where id = ?",
|
||||
Integer.class, draftId)).isEqualTo(1);
|
||||
assertThat(savedDraft.getPasswordNonce()).hasSize(12);
|
||||
assertThat(savedDraft.getSecretKeyVersion()).isEqualTo(1);
|
||||
smtpProbe.fail = true;
|
||||
assertThatThrownBy(() -> smtpService.testDraft(draftId, adminId, "admin@example.com"))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId))
|
||||
.isEqualTo("DRAFT");
|
||||
assertThat(jdbc.queryForObject("select tested_at is null from smtp_configurations where id = ?", Boolean.class,
|
||||
draftId)).isTrue();
|
||||
assertThat(configurations.findById(draftId).orElseThrow().getStatus()).isEqualTo(SmtpStatus.DRAFT);
|
||||
assertThat(configurations.findById(draftId).orElseThrow().getTestedAt()).isNull();
|
||||
assertThatThrownBy(() -> smtpService.activate(draftId, adminId)).isInstanceOf(IllegalStateException.class);
|
||||
|
||||
smtpProbe.fail = false;
|
||||
smtpService.testDraft(draftId, adminId, "admin@example.com");
|
||||
smtpService.activate(draftId, adminId);
|
||||
|
||||
assertThat(jdbc.queryForObject("select status from smtp_configurations where id = ?", String.class, draftId))
|
||||
.isEqualTo("ACTIVE");
|
||||
assertThat(configurations.findById(draftId).orElseThrow().getStatus()).isEqualTo(SmtpStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
@@ -76,7 +81,7 @@ class SmtpIntegrationTest extends PlatformDatabaseTestSupport {
|
||||
private boolean fail;
|
||||
|
||||
@Override
|
||||
public void send(SmtpConfigurationService.SmtpConnection connection, String recipient, String subject,
|
||||
public void send(SmtpConnection connection, String recipient, String subject,
|
||||
String body) {
|
||||
if (fail) {
|
||||
throw new IllegalStateException("simulated SMTP failure");
|
||||
Reference in New Issue
Block a user