Merge commit '1235204bf1298599264a07943ca1167432556bd2' into work/reports-ui
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
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")
|
||||
@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());
|
||||
mockMvc.perform(get("/actuator/health")).andExpect(status().isOk());
|
||||
mockMvc.perform(get("/")).andExpect(status().isNotFound());
|
||||
|
||||
bootstrapService.bootstrap("admin@example.com", "Admin", "correct horse battery staple");
|
||||
mockMvc.perform(get("/bootstrap")).andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentBootstrapCreatesExactlyOneAdminAndPermanentlyCloses() throws Exception {
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<BootstrapService.BootstrapOutcome>> futures = new ArrayList<>();
|
||||
|
||||
try (var executor = Executors.newFixedThreadPool(2)) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
int suffix = i;
|
||||
futures.add(executor.submit(() -> {
|
||||
ready.countDown();
|
||||
start.await();
|
||||
return bootstrapService.bootstrap(
|
||||
"admin" + suffix + "@example.com", "First Admin", "correct horse battery staple");
|
||||
}));
|
||||
}
|
||||
ready.await();
|
||||
start.countDown();
|
||||
}
|
||||
|
||||
assertThat(futures).extracting(future -> future.get()).containsExactlyInAnyOrder(
|
||||
BootstrapService.BootstrapOutcome.CREATED, BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
|
||||
assertThat(users.count()).isEqualTo(1);
|
||||
assertThat(users.countByGlobalRoleAndAccountStatus(GlobalRole.ADMIN, AccountStatus.ACTIVE)).isEqualTo(1);
|
||||
assertThat(bootstrapService.bootstrap(
|
||||
"another@example.com", "Another", "correct horse battery staple"))
|
||||
.isEqualTo(BootstrapService.BootstrapOutcome.ALREADY_INITIALIZED);
|
||||
assertThat(systemStates.findById((short) 1).orElseThrow().isInitialized()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesIdentityAndDateAwareInternEligibilityWithoutPersistenceTypes() {
|
||||
bootstrapService.bootstrap("admin@example.com", "First Admin", "correct horse battery staple");
|
||||
|
||||
var identityByEmail = accountService.requireIdentityByEmail(" ADMIN@EXAMPLE.COM ");
|
||||
assertThat(identityByEmail.email()).isEqualTo("admin@example.com");
|
||||
assertThat(identityByEmail.displayName()).isEqualTo("First Admin");
|
||||
assertThat(identityByEmail.role()).isEqualTo(GlobalRole.ADMIN);
|
||||
assertThat(identityByEmail.status()).isEqualTo(AccountStatus.ACTIVE);
|
||||
assertThat(accountService.requireIdentityById(identityByEmail.id())).isEqualTo(identityByEmail);
|
||||
assertThat(accountService.isEligibleIntern(identityByEmail.id())).isFalse();
|
||||
assertThat(accountService.isEligibleIntern(identityByEmail.id(), LocalDate.of(2026, 8, 14))).isFalse();
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
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.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;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@Import({TestcontainersConfiguration.class, SmtpIntegrationTest.MailProbeConfiguration.class})
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class SmtpIntegrationTest {
|
||||
|
||||
@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 = accountService.requireActiveAdminId("admin@example.com");
|
||||
long draftId = smtpService.saveDraft(adminId, new SmtpDraft(
|
||||
"mailpit", 1025, SecurityMode.NONE, "smtp-user", "smtp-password", "admin@example.com", "Lab"));
|
||||
|
||||
var savedDraft = configurations.findById(draftId).orElseThrow();
|
||||
byte[] ciphertext = savedDraft.getPasswordCiphertext();
|
||||
assertThat(new String(ciphertext, StandardCharsets.ISO_8859_1)).doesNotContain("smtp-password");
|
||||
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(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(configurations.findById(draftId).orElseThrow().getStatus()).isEqualTo(SmtpStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
static class MailProbeConfiguration {
|
||||
@Bean
|
||||
@Primary
|
||||
RecordingSmtpProbe recordingSmtpProbe() {
|
||||
return new RecordingSmtpProbe();
|
||||
}
|
||||
}
|
||||
|
||||
static final class RecordingSmtpProbe implements SmtpProbe {
|
||||
private boolean fail;
|
||||
|
||||
@Override
|
||||
public void send(SmtpConnection connection, String recipient, String subject,
|
||||
String body) {
|
||||
if (fail) {
|
||||
throw new IllegalStateException("simulated SMTP failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user