diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedConfiguration.java b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedConfiguration.java index 63d99375..e5168e92 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedConfiguration.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedConfiguration.java @@ -3,6 +3,7 @@ import com.fowoco.server.auth.application.port.UserAccountRepository; import com.fowoco.server.company.application.port.CompanyRepository; import com.fowoco.server.company.application.port.CompanySettingsProvisioner; +import com.fowoco.server.common.security.TenantTransactionExecutor; import java.time.Clock; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -22,7 +23,8 @@ DemoAuthSeedRunner demoAuthSeedRunner( CompanySettingsProvisioner companySettingsProvisioner, UserAccountRepository userAccountRepository, PasswordEncoder passwordEncoder, - Clock clock + Clock clock, + TenantTransactionExecutor tenantTransactionExecutor ) { return new DemoAuthSeedRunner( properties, @@ -30,7 +32,8 @@ DemoAuthSeedRunner demoAuthSeedRunner( companySettingsProvisioner, userAccountRepository, passwordEncoder, - clock + clock, + tenantTransactionExecutor ); } } diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java index 45df409a..9a280550 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunner.java @@ -6,6 +6,7 @@ import com.fowoco.server.company.application.port.CompanyRepository; import com.fowoco.server.company.application.port.CompanySettingsProvisioner; import com.fowoco.server.company.domain.Company; +import com.fowoco.server.common.security.TenantTransactionExecutor; import java.time.Clock; import java.time.Instant; import java.util.List; @@ -18,7 +19,6 @@ import org.springframework.boot.ApplicationRunner; import org.springframework.core.annotation.Order; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.transaction.annotation.Transactional; @Order(0) class DemoAuthSeedRunner implements ApplicationRunner { @@ -58,6 +58,7 @@ class DemoAuthSeedRunner implements ApplicationRunner { private final UserAccountRepository userAccountRepository; private final PasswordEncoder passwordEncoder; private final Clock clock; + private final TenantTransactionExecutor tenantTransactionExecutor; DemoAuthSeedRunner( DemoAuthSeedProperties properties, @@ -65,7 +66,8 @@ class DemoAuthSeedRunner implements ApplicationRunner { CompanySettingsProvisioner companySettingsProvisioner, UserAccountRepository userAccountRepository, PasswordEncoder passwordEncoder, - Clock clock + Clock clock, + TenantTransactionExecutor tenantTransactionExecutor ) { this.properties = Objects.requireNonNull(properties, "properties must not be null"); this.companyRepository = Objects.requireNonNull( @@ -82,24 +84,30 @@ class DemoAuthSeedRunner implements ApplicationRunner { ); this.passwordEncoder = Objects.requireNonNull(passwordEncoder, "passwordEncoder must not be null"); this.clock = Objects.requireNonNull(clock, "clock must not be null"); + this.tenantTransactionExecutor = Objects.requireNonNull( + tenantTransactionExecutor, + "tenantTransactionExecutor must not be null" + ); } @Override - @Transactional public void run(ApplicationArguments arguments) { validateConfiguration(); Instant now = clock.instant(); - ensureCompany(properties.companyId(), properties.companyName(), now); - ensureCompany(properties.testCompanyId(), properties.testCompanyName(), now); - - seedUser(properties.companyId(), new DemoUser( - properties.adminUserId(), - properties.adminDisplayName(), - properties.adminEmail(), - UserRole.ADMIN - ), now); - DEMO_USERS.forEach(user -> seedUser(properties.companyId(), user, now)); - TEST_USERS.forEach(user -> seedUser(properties.testCompanyId(), user, now)); + tenantTransactionExecutor.execute(properties.companyId(), () -> { + ensureCompany(properties.companyId(), properties.companyName(), now); + seedUser(properties.companyId(), new DemoUser( + properties.adminUserId(), + properties.adminDisplayName(), + properties.adminEmail(), + UserRole.ADMIN + ), now); + DEMO_USERS.forEach(user -> seedUser(properties.companyId(), user, now)); + }); + tenantTransactionExecutor.execute(properties.testCompanyId(), () -> { + ensureCompany(properties.testCompanyId(), properties.testCompanyName(), now); + TEST_USERS.forEach(user -> seedUser(properties.testCompanyId(), user, now)); + }); LOGGER.info( "demo_auth_seed ready company_count={} user_count={}", 2, diff --git a/src/main/java/com/fowoco/server/common/security/TenantTransactionExecutor.java b/src/main/java/com/fowoco/server/common/security/TenantTransactionExecutor.java new file mode 100644 index 00000000..2536ce27 --- /dev/null +++ b/src/main/java/com/fowoco/server/common/security/TenantTransactionExecutor.java @@ -0,0 +1,44 @@ +package com.fowoco.server.common.security; + +import java.util.Objects; +import java.util.UUID; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Executes work in an independent transaction bound to one trusted tenant. + */ +@Component +public final class TenantTransactionExecutor { + + private final TransactionTemplate transactionTemplate; + private final TenantDatabaseContext tenantDatabaseContext; + + public TenantTransactionExecutor( + PlatformTransactionManager transactionManager, + TenantDatabaseContext tenantDatabaseContext + ) { + this.transactionTemplate = new TransactionTemplate(Objects.requireNonNull( + transactionManager, + "transactionManager must not be null" + )); + this.transactionTemplate.setPropagationBehavior( + TransactionDefinition.PROPAGATION_REQUIRES_NEW + ); + this.tenantDatabaseContext = Objects.requireNonNull( + tenantDatabaseContext, + "tenantDatabaseContext must not be null" + ); + } + + public void execute(UUID companyId, Runnable callback) { + Objects.requireNonNull(companyId, "companyId must not be null"); + Objects.requireNonNull(callback, "callback must not be null"); + transactionTemplate.executeWithoutResult(status -> { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + callback.run(); + }); + } +} diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java index 27303780..65677dc4 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedConfiguration.java @@ -6,6 +6,7 @@ import com.fowoco.server.approval.application.port.ExternalSubmissionRepository; import com.fowoco.server.approval.application.SafeJsonService; import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import com.fowoco.server.common.security.TenantTransactionExecutor; import com.fowoco.server.document.application.port.DocumentRequestDraftRepository; import com.fowoco.server.file.application.port.FileStorage; import com.fowoco.server.file.application.port.StoredFileRepository; @@ -48,6 +49,7 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( JdbcTemplate jdbcTemplate, ObjectMapper objectMapper, Clock clock, + TenantTransactionExecutor tenantTransactionExecutor, @Value("${app.file-storage.local-path}") String localFileStoragePath ) { if (!(fileStorage instanceof LocalFileStorage)) { @@ -121,7 +123,8 @@ DemoOperationalSeedRunner demoOperationalSeedRunner( evidenceSeeder, requestDraftSeeder, auditSeeder, - verifier + verifier, + tenantTransactionExecutor ); } } diff --git a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java index f3780b8d..e394c351 100644 --- a/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java +++ b/src/main/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunner.java @@ -1,6 +1,7 @@ package com.fowoco.server.demo.infrastructure.seed; import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import com.fowoco.server.common.security.TenantTransactionExecutor; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.AuditSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ApprovalSeed; import com.fowoco.server.demo.infrastructure.seed.DemoOperationalSeedCatalog.ChecklistSeed; @@ -21,7 +22,6 @@ import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.core.annotation.Order; -import org.springframework.transaction.annotation.Transactional; @Order(2) class DemoOperationalSeedRunner implements ApplicationRunner { @@ -44,6 +44,7 @@ class DemoOperationalSeedRunner implements ApplicationRunner { private final DemoDocumentRequestDraftSeeder requestDraftSeeder; private final DemoAuditEventSeeder auditSeeder; private final DemoOperationalSeedVerifier verifier; + private final TenantTransactionExecutor tenantTransactionExecutor; DemoOperationalSeedRunner( DemoAuthSeedProperties properties, @@ -61,7 +62,8 @@ class DemoOperationalSeedRunner implements ApplicationRunner { DemoEvidenceSeeder evidenceSeeder, DemoDocumentRequestDraftSeeder requestDraftSeeder, DemoAuditEventSeeder auditSeeder, - DemoOperationalSeedVerifier verifier + DemoOperationalSeedVerifier verifier, + TenantTransactionExecutor tenantTransactionExecutor ) { this.properties = Objects.requireNonNull(properties, "properties must not be null"); this.clock = Objects.requireNonNull(clock, "clock must not be null"); @@ -94,30 +96,35 @@ class DemoOperationalSeedRunner implements ApplicationRunner { ); this.auditSeeder = Objects.requireNonNull(auditSeeder, "auditSeeder must not be null"); this.verifier = Objects.requireNonNull(verifier, "verifier must not be null"); + this.tenantTransactionExecutor = Objects.requireNonNull( + tenantTransactionExecutor, + "tenantTransactionExecutor must not be null" + ); } @Override - @Transactional public void run(ApplicationArguments arguments) { Instant now = clock.instant(); LocalDate today = LocalDate.now(clock); DemoOperationalSeedContext demoContext = DemoOperationalSeedContext.demo(properties, today, now); DemoOperationalSeedContext testContext = DemoOperationalSeedContext.test(properties, today, now); - goldenFlowStateGuard.verifyNoLegacyRows(demoContext); - seedDataset( - catalog.demoTasks(), - catalog.demoStoredFiles(), - catalog.demoDocuments(), - catalog.demoChecklists(), - catalog.demoApprovals(), - catalog.demoTransitions(), - catalog.demoExternalSubmissions(), - catalog.demoEvidence(), - catalog.demoDocumentRequestDrafts(), - catalog.demoAudits(), - demoContext - ); - seedDataset( + tenantTransactionExecutor.execute(demoContext.companyId(), () -> { + goldenFlowStateGuard.verifyNoLegacyRows(demoContext); + seedDataset( + catalog.demoTasks(), + catalog.demoStoredFiles(), + catalog.demoDocuments(), + catalog.demoChecklists(), + catalog.demoApprovals(), + catalog.demoTransitions(), + catalog.demoExternalSubmissions(), + catalog.demoEvidence(), + catalog.demoDocumentRequestDrafts(), + catalog.demoAudits(), + demoContext + ); + }); + tenantTransactionExecutor.execute(testContext.companyId(), () -> seedDataset( catalog.testTasks(), List.of(), catalog.testDocuments(), @@ -129,7 +136,7 @@ public void run(ApplicationArguments arguments) { List.of(), catalog.testAudits(), testContext - ); + )); LOGGER.info( "demo_operational_seed ready demo_task_count={} demo_stored_file_count={} " + "demo_document_count={} " diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedConfiguration.java b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedConfiguration.java index 71c5de7b..1f76cccd 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedConfiguration.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedConfiguration.java @@ -2,6 +2,7 @@ import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; import com.fowoco.server.company.application.port.CompanyRepository; +import com.fowoco.server.common.security.TenantTransactionExecutor; import com.fowoco.server.worker.application.port.WorkerRepository; import java.time.Clock; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -17,13 +18,15 @@ DemoWorkerSeedRunner demoWorkerSeedRunner( DemoAuthSeedProperties properties, CompanyRepository companyRepository, WorkerRepository workerRepository, - Clock clock + Clock clock, + TenantTransactionExecutor tenantTransactionExecutor ) { return new DemoWorkerSeedRunner( properties, companyRepository, workerRepository, - clock + clock, + tenantTransactionExecutor ); } } diff --git a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java index 8d02aa78..d0149765 100644 --- a/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java +++ b/src/main/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunner.java @@ -3,6 +3,7 @@ import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; import com.fowoco.server.company.application.port.CompanyRepository; import com.fowoco.server.company.domain.Company; +import com.fowoco.server.common.security.TenantTransactionExecutor; import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.Worker; import com.fowoco.server.worker.infrastructure.seed.DemoWorkerSeedCatalog.WorkerSeed; @@ -18,7 +19,6 @@ import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.core.annotation.Order; -import org.springframework.transaction.annotation.Transactional; @Order(1) class DemoWorkerSeedRunner implements ApplicationRunner { @@ -29,12 +29,14 @@ class DemoWorkerSeedRunner implements ApplicationRunner { private final WorkerRepository workerRepository; private final Clock clock; private final DemoWorkerSeedCatalog catalog; + private final TenantTransactionExecutor tenantTransactionExecutor; DemoWorkerSeedRunner( DemoAuthSeedProperties properties, CompanyRepository companyRepository, WorkerRepository workerRepository, - Clock clock + Clock clock, + TenantTransactionExecutor tenantTransactionExecutor ) { this.properties = Objects.requireNonNull(properties, "properties must not be null"); this.companyRepository = Objects.requireNonNull( @@ -44,15 +46,24 @@ class DemoWorkerSeedRunner implements ApplicationRunner { this.workerRepository = Objects.requireNonNull(workerRepository, "workerRepository must not be null"); this.clock = Objects.requireNonNull(clock, "clock must not be null"); this.catalog = new DemoWorkerSeedCatalog(); + this.tenantTransactionExecutor = Objects.requireNonNull( + tenantTransactionExecutor, + "tenantTransactionExecutor must not be null" + ); } @Override - @Transactional public void run(ApplicationArguments arguments) { Instant now = clock.instant(); LocalDate today = LocalDate.now(clock); - seedCompanyWorkers(properties.companyId(), catalog.demoWorkers(), today, now); - seedCompanyWorkers(properties.testCompanyId(), catalog.testWorkers(), today, now); + tenantTransactionExecutor.execute( + properties.companyId(), + () -> seedCompanyWorkers(properties.companyId(), catalog.demoWorkers(), today, now) + ); + tenantTransactionExecutor.execute( + properties.testCompanyId(), + () -> seedCompanyWorkers(properties.testCompanyId(), catalog.testWorkers(), today, now) + ); LOGGER.info( "demo_worker_seed ready company_count={} worker_count={}", 2, diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java index db7863a6..c03c3d6c 100644 --- a/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/auth/infrastructure/seed/DemoAuthSeedRunnerTest.java @@ -2,6 +2,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import com.fowoco.server.auth.application.port.UserAccountRepository; import com.fowoco.server.auth.domain.UserAccount; @@ -10,6 +14,7 @@ import com.fowoco.server.company.application.port.CompanySettingsProvisioner; import com.fowoco.server.company.domain.Company; import com.fowoco.server.company.domain.CompanyStatus; +import com.fowoco.server.common.security.TenantTransactionExecutor; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; @@ -41,12 +46,14 @@ void createsAnIdempotentAdminSeedAndStoresOnlyThePasswordHash() throws Exception RecordingCompanySettingsProvisioner settingsProvisioner = new RecordingCompanySettingsProvisioner(); PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4); + TenantTransactionExecutor transactionExecutor = immediateTenantTransactionExecutor(); DemoAuthSeedRunner runner = runner( properties(ADMIN_PASSWORD), companyRepository, settingsProvisioner, userAccountRepository, - passwordEncoder + passwordEncoder, + transactionExecutor ); runner.run(new DefaultApplicationArguments(new String[0])); @@ -81,6 +88,10 @@ void createsAnIdempotentAdminSeedAndStoresOnlyThePasswordHash() throws Exception .containsExactlyInAnyOrder(UserRole.ADMIN, UserRole.HR, UserRole.VIEWER); assertThat(userAccountRepository.users.values()) .allMatch(user -> passwordEncoder.matches(ADMIN_PASSWORD, user.passwordHash())); + verify(transactionExecutor, org.mockito.Mockito.times(2)) + .execute(org.mockito.ArgumentMatchers.eq(COMPANY_ID), any(Runnable.class)); + verify(transactionExecutor, org.mockito.Mockito.times(2)) + .execute(org.mockito.ArgumentMatchers.eq(TEST_COMPANY_ID), any(Runnable.class)); } @Test @@ -239,10 +250,39 @@ private DemoAuthSeedRunner runner( companySettingsProvisioner, userAccountRepository, passwordEncoder, - Clock.fixed(NOW, ZoneOffset.UTC) + Clock.fixed(NOW, ZoneOffset.UTC), + immediateTenantTransactionExecutor() ); } + private DemoAuthSeedRunner runner( + DemoAuthSeedProperties properties, + CompanyRepository companyRepository, + CompanySettingsProvisioner companySettingsProvisioner, + UserAccountRepository userAccountRepository, + PasswordEncoder passwordEncoder, + TenantTransactionExecutor tenantTransactionExecutor + ) { + return new DemoAuthSeedRunner( + properties, + companyRepository, + companySettingsProvisioner, + userAccountRepository, + passwordEncoder, + Clock.fixed(NOW, ZoneOffset.UTC), + tenantTransactionExecutor + ); + } + + private TenantTransactionExecutor immediateTenantTransactionExecutor() { + TenantTransactionExecutor executor = mock(TenantTransactionExecutor.class); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(executor).execute(any(UUID.class), any(Runnable.class)); + return executor; + } + private static final class RecordingCompanySettingsProvisioner implements CompanySettingsProvisioner { diff --git a/src/test/java/com/fowoco/server/common/security/TenantTransactionExecutorTest.java b/src/test/java/com/fowoco/server/common/security/TenantTransactionExecutorTest.java new file mode 100644 index 00000000..1fa559ac --- /dev/null +++ b/src/test/java/com/fowoco/server/common/security/TenantTransactionExecutorTest.java @@ -0,0 +1,83 @@ +package com.fowoco.server.common.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; + +class TenantTransactionExecutorTest { + + private static final UUID COMPANY_A = + UUID.fromString("a0000000-0000-0000-0000-000000000001"); + private static final UUID COMPANY_B = + UUID.fromString("b0000000-0000-0000-0000-000000000002"); + + private JdbcTemplate jdbcTemplate; + private DataSourceTransactionManager transactionManager; + private AtomicReference boundCompany; + private TenantTransactionExecutor executor; + + @BeforeEach + void setUp() { + JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:tenant-transaction-" + UUID.randomUUID() + + ";DB_CLOSE_DELAY=-1"); + jdbcTemplate = new JdbcTemplate(dataSource); + jdbcTemplate.execute("CREATE TABLE seed_probe (company_id UUID PRIMARY KEY)"); + transactionManager = new DataSourceTransactionManager(dataSource); + boundCompany = new AtomicReference<>(); + executor = new TenantTransactionExecutor(transactionManager, companyId -> { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isTrue(); + boundCompany.set(companyId); + }); + } + + @Test + void bindsTenantBeforeWorkAndRollsBackOnlyTheFailingCompany() { + executor.execute(COMPANY_A, () -> { + assertThat(boundCompany).hasValue(COMPANY_A); + jdbcTemplate.update("INSERT INTO seed_probe (company_id) VALUES (?)", COMPANY_A); + }); + + assertThatThrownBy(() -> executor.execute(COMPANY_B, () -> { + assertThat(boundCompany).hasValue(COMPANY_B); + jdbcTemplate.update("INSERT INTO seed_probe (company_id) VALUES (?)", COMPANY_B); + throw new ExpectedFailure(); + })).isInstanceOf(ExpectedFailure.class); + + assertThat(jdbcTemplate.queryForList( + "SELECT company_id FROM seed_probe ORDER BY company_id", + UUID.class + )).containsExactly(COMPANY_A); + } + + @Test + void commitsInRequiresNewEvenWhenTheCallingTransactionRollsBack() { + TransactionTemplate outer = new TransactionTemplate(transactionManager); + + outer.executeWithoutResult(status -> { + executor.execute(COMPANY_A, () -> jdbcTemplate.update( + "INSERT INTO seed_probe (company_id) VALUES (?)", + COMPANY_A + )); + status.setRollbackOnly(); + }); + + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM seed_probe WHERE company_id = ?", + Integer.class, + COMPANY_A + )).isEqualTo(1); + } + + private static final class ExpectedFailure extends RuntimeException { + } +} diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunnerTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunnerTest.java new file mode 100644 index 00000000..965a3767 --- /dev/null +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoOperationalSeedRunnerTest.java @@ -0,0 +1,74 @@ +package com.fowoco.server.demo.infrastructure.seed; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; + +import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; +import com.fowoco.server.common.security.TenantTransactionExecutor; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.boot.DefaultApplicationArguments; + +class DemoOperationalSeedRunnerTest { + + private static final UUID DEMO_COMPANY_ID = + UUID.fromString("90000000-0000-0000-0000-000000000001"); + private static final UUID TEST_COMPANY_ID = + UUID.fromString("91000000-0000-0000-0000-000000000001"); + + @Test + void seedsDemoAndTestDatasetsInSeparateOrderedTenantTransactions() throws Exception { + TenantTransactionExecutor transactionExecutor = mock(TenantTransactionExecutor.class); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(transactionExecutor).execute(any(UUID.class), any(Runnable.class)); + + DemoOperationalSeedRunner runner = new DemoOperationalSeedRunner( + properties(), + Clock.fixed(Instant.parse("2026-08-17T00:00:00Z"), ZoneOffset.UTC), + new DemoOperationalSeedCatalog(), + mock(DemoGoldenFlowSeedStateGuard.class), + mock(DemoCaseSeeder.class), + mock(DemoTaskSeeder.class), + mock(DemoWorkerDocumentSeeder.class), + mock(DemoTaskChecklistSeeder.class), + mock(DemoApprovalRequestSeeder.class), + mock(DemoStoredFileSeeder.class), + mock(DemoTaskTransitionSeeder.class), + mock(DemoExternalSubmissionSeeder.class), + mock(DemoEvidenceSeeder.class), + mock(DemoDocumentRequestDraftSeeder.class), + mock(DemoAuditEventSeeder.class), + mock(DemoOperationalSeedVerifier.class), + transactionExecutor + ); + + runner.run(new DefaultApplicationArguments(new String[0])); + + InOrder order = inOrder(transactionExecutor); + order.verify(transactionExecutor).execute(eq(DEMO_COMPANY_ID), any(Runnable.class)); + order.verify(transactionExecutor).execute(eq(TEST_COMPANY_ID), any(Runnable.class)); + } + + private DemoAuthSeedProperties properties() { + return new DemoAuthSeedProperties( + true, + DEMO_COMPANY_ID, + "FOWOCO Demo Company", + TEST_COMPANY_ID, + "FOWOCO Test Company", + UUID.fromString("90000000-0000-0000-0000-000000000002"), + "데모 관리자", + "demo.admin@example.com", + "Demo-password-1!" + ); + } +} diff --git a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoSeedPostgreSqlApplicationIntegrationTest.java b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoSeedPostgreSqlApplicationIntegrationTest.java index fac8d3de..72494ce7 100644 --- a/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoSeedPostgreSqlApplicationIntegrationTest.java +++ b/src/test/java/com/fowoco/server/demo/infrastructure/seed/DemoSeedPostgreSqlApplicationIntegrationTest.java @@ -6,14 +6,28 @@ import com.fowoco.server.auth.application.ActorContext; import com.fowoco.server.auth.domain.UserRole; import com.fowoco.server.common.security.PostgreSqlRlsTestLock; +import com.fowoco.server.common.security.PostgreSqlRlsStateFixture; +import com.fowoco.server.common.security.TenantTransactionExecutor; import com.fowoco.server.task.application.TaskResult; import com.fowoco.server.task.application.TaskWorkflowService; +import java.nio.file.Files; import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; import java.sql.Timestamp; +import java.util.HexFormat; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.flywaydb.core.Flyway; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.junit.jupiter.api.io.TempDir; @@ -23,12 +37,15 @@ import org.springframework.core.env.MapPropertySource; import org.springframework.core.env.StandardEnvironment; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; @EnabledIfEnvironmentVariable(named = "POSTGRES_TEST_ENABLED", matches = "true") class DemoSeedPostgreSqlApplicationIntegrationTest { private static final UUID COMPANY_ID = UUID.fromString("90000000-0000-0000-0000-000000000001"); + private static final UUID TEST_COMPANY_ID = + UUID.fromString("91000000-0000-0000-0000-000000000001"); private static final UUID ADMIN_USER_ID = UUID.fromString("90000000-0000-0000-0000-000000000002"); private static final UUID TASK_ID = @@ -52,52 +69,167 @@ class DemoSeedPostgreSqlApplicationIntegrationTest { Map.entry("document_request_draft", 4), Map.entry("audit_event", 94) ); + private static final Map EXPECTED_TEST_COUNTS = Map.of( + "user_account", 3, + "worker", 5, + "workflow_case", 3, + "task", 3, + "worker_document", 8, + "audit_event", 8 + ); + private static final List SEED_TABLES = List.of( + "company", + "company_settings", + "user_account", + "worker", + "workflow_case", + "task", + "worker_document", + "stored_file", + "task_checklist_item", + "approval_request", + "task_transition_history", + "external_submission", + "task_evidence", + "document_request_draft", + "document_request_draft_type", + "audit_event" + ); + private static final List RUNTIME_READ_TABLES = List.of( + "worker_archive" + ); @TempDir Path fileStoragePath; @Test - void bootsFullDemoSeedUpgradesPreviousReleaseAndRestartsIdempotentlyOnPostgreSql16() + void preservesExistingSeedWhenRlsIsEnabledAndRestrictedRuntimeRestarts() throws Exception { String url = requiredEnvironmentVariable("POSTGRES_TEST_URL"); - String username = requiredEnvironmentVariable("POSTGRES_TEST_USERNAME"); - String password = requiredEnvironmentVariable("POSTGRES_TEST_PASSWORD"); + String migrationUsername = requiredEnvironmentVariable("POSTGRES_TEST_USERNAME"); + String migrationPassword = requiredEnvironmentVariable("POSTGRES_TEST_PASSWORD"); try (PostgreSqlRlsTestLock ignored = PostgreSqlRlsTestLock.acquire( url, - username, - password + migrationUsername, + migrationPassword )) { - SeedSnapshot firstBoot; - try (ConfigurableApplicationContext context = startApplication( + migrate(url, migrationUsername, migrationPassword); + String runtimeRole = "demo_seed_runtime_" + + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + String runtimePassword = "Demo-seed-runtime-" + UUID.randomUUID(); + try (Connection migrationConnection = DriverManager.getConnection( url, - username, - password + migrationUsername, + migrationPassword + ); PostgreSqlRlsStateFixture rlsState = PostgreSqlRlsStateFixture.capture( + migrationConnection, + policyTables(migrationConnection) )) { - JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class); - assertGoldenFlowStartState(jdbcTemplate); - assertTaskDetailReadable(context); - firstBoot = snapshot(jdbcTemplate); - emulatePreviousReleaseSeed(jdbcTemplate); - } + createRestrictedRuntimeRole(migrationConnection, runtimeRole, runtimePassword); + try { + rlsState.disableRowLevelSecurityForFixtureSetup(); + SeedSnapshot existingSeed; + Map existingFiles; + try (ConfigurableApplicationContext context = startApplication( + url, + migrationUsername, + migrationPassword, + runtimeRole, + runtimePassword + )) { + JdbcTemplate migrationJdbc = migrationJdbc( + url, + migrationUsername, + migrationPassword + ); + assertGoldenFlowStartState(migrationJdbc); + assertTaskDetailReadable(context); + existingSeed = snapshot(migrationJdbc); + existingFiles = fileSnapshot(); + } - try (ConfigurableApplicationContext context = startApplication( - url, - username, - password - )) { - JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class); - assertGoldenFlowStartState(jdbcTemplate); - assertTaskDetailReadable(context); - assertThat(snapshot(jdbcTemplate)).isEqualTo(firstBoot); - } + rlsState.enableRowLevelSecurity(); + try (ConfigurableApplicationContext context = startApplication( + url, + migrationUsername, + migrationPassword, + runtimeRole, + runtimePassword + )) { + JdbcTemplate migrationJdbc = migrationJdbc( + url, + migrationUsername, + migrationPassword + ); + assertRestrictedRuntime(context, migrationJdbc, runtimeRole); + assertGoldenFlowStartState(migrationJdbc); + assertTaskDetailReadable(context); + assertThat(snapshot(migrationJdbc)).isEqualTo(existingSeed); + assertThat(fileSnapshot()).isEqualTo(existingFiles); + } - try (ConfigurableApplicationContext context = startApplication( - url, - username, - password - )) { - assertThat(snapshot(context.getBean(JdbcTemplate.class))).isEqualTo(firstBoot); + JdbcTemplate migrationJdbc = migrationJdbc( + url, + migrationUsername, + migrationPassword + ); + emulatePreviousReleaseSeed(migrationJdbc); + SeedSnapshot repairedSeed; + try (ConfigurableApplicationContext context = startApplication( + url, + migrationUsername, + migrationPassword, + runtimeRole, + runtimePassword + )) { + assertRestrictedRuntime(context, migrationJdbc, runtimeRole); + repairedSeed = snapshot(migrationJdbc); + assertSameStructure(repairedSeed, existingSeed); + assertThat(fileSnapshot()).isEqualTo(existingFiles); + } + + try (ConfigurableApplicationContext context = startApplication( + url, + migrationUsername, + migrationPassword, + runtimeRole, + runtimePassword + )) { + assertRestrictedRuntime(context, migrationJdbc, runtimeRole); + assertThat(snapshot(migrationJdbc)).isEqualTo(repairedSeed); + assertThat(fileSnapshot()).isEqualTo(existingFiles); + } + + rlsState.disableRowLevelSecurityForFixtureSetup(); + deleteSeedFixtures(migrationJdbc); + rlsState.enableRowLevelSecurity(); + SeedSnapshot freshRlsSeed; + try (ConfigurableApplicationContext context = startApplication( + url, + migrationUsername, + migrationPassword, + runtimeRole, + runtimePassword + )) { + assertRestrictedRuntime(context, migrationJdbc, runtimeRole); + freshRlsSeed = snapshot(migrationJdbc); + assertThat(fileSnapshot()).isEqualTo(existingFiles); + } + try (ConfigurableApplicationContext context = startApplication( + url, + migrationUsername, + migrationPassword, + runtimeRole, + runtimePassword + )) { + assertRestrictedRuntime(context, migrationJdbc, runtimeRole); + assertThat(snapshot(migrationJdbc)).isEqualTo(freshRlsSeed); + assertThat(fileSnapshot()).isEqualTo(existingFiles); + } + } finally { + dropRestrictedRuntimeRole(migrationConnection, runtimeRole); + } } } } @@ -145,21 +277,317 @@ private void emulatePreviousReleaseSeed(JdbcTemplate jdbcTemplate) { assertThat(countByCompany(jdbcTemplate, "audit_event", COMPANY_ID)).isEqualTo(88); } + private void deleteSeedFixtures(JdbcTemplate jdbcTemplate) { + List dependencyOrder = List.of( + "audit_event", + "document_request_draft_type", + "document_request_draft", + "task_evidence", + "external_submission", + "task_transition_history", + "approval_request", + "task_checklist_item", + "worker_document", + "stored_file", + "task", + "workflow_case", + "worker", + "user_account", + "company_settings", + "company" + ); + for (String table : dependencyOrder) { + if ("document_request_draft_type".equals(table)) { + jdbcTemplate.update( + """ + DELETE FROM document_request_draft_type + WHERE draft_id IN ( + SELECT draft_id + FROM document_request_draft + WHERE company_id IN (?, ?) + ) + """, + COMPANY_ID, + TEST_COMPANY_ID + ); + continue; + } + jdbcTemplate.update( + "DELETE FROM " + table + " WHERE company_id IN (?, ?)", + COMPANY_ID, + TEST_COMPANY_ID + ); + } + assertThat(countByCompany(jdbcTemplate, "company", COMPANY_ID)).isZero(); + assertThat(countByCompany(jdbcTemplate, "company", TEST_COMPANY_ID)).isZero(); + } + + private void migrate(String url, String username, String password) { + Flyway.configure() + .dataSource(url, username, password) + .locations("classpath:db/migration", "classpath:db/migration-postgresql") + .load() + .migrate(); + } + + private JdbcTemplate migrationJdbc(String url, String username, String password) { + return new JdbcTemplate(new DriverManagerDataSource(url, username, password)); + } + + private List policyTables(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + """ + SELECT DISTINCT tablename + FROM pg_catalog.pg_policies + WHERE schemaname = 'public' + ORDER BY tablename + """ + )) { + var tables = new java.util.ArrayList(); + while (resultSet.next()) { + tables.add(resultSet.getString(1)); + } + assertThat(tables).containsAll(SEED_TABLES); + return List.copyOf(tables); + } + } + + private void createRestrictedRuntimeRole( + Connection connection, + String runtimeRole, + String runtimePassword + ) throws SQLException { + String quotedRole = quoteIdentifier(runtimeRole); + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE ROLE %s + LOGIN + PASSWORD %s + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOREPLICATION + NOBYPASSRLS + """.formatted(quotedRole, quoteLiteral(runtimePassword))); + statement.execute( + "GRANT CONNECT ON DATABASE " + quoteIdentifier(connection.getCatalog()) + + " TO " + quotedRole + ); + statement.execute("GRANT USAGE ON SCHEMA public TO " + quotedRole); + for (String table : SEED_TABLES) { + statement.execute( + "GRANT SELECT, INSERT, UPDATE ON TABLE public." + + quoteIdentifier(table) + " TO " + quotedRole + ); + } + for (String table : RUNTIME_READ_TABLES) { + statement.execute( + "GRANT SELECT ON TABLE public." + + quoteIdentifier(table) + " TO " + quotedRole + ); + } + } + } + + private void dropRestrictedRuntimeRole(Connection connection, String runtimeRole) + throws SQLException { + String quotedRole = quoteIdentifier(runtimeRole); + try (Statement statement = connection.createStatement()) { + statement.execute("DROP OWNED BY " + quotedRole); + statement.execute("DROP ROLE " + quotedRole); + } + } + + private void assertRestrictedRuntime( + ConfigurableApplicationContext context, + JdbcTemplate migrationJdbc, + String runtimeRole + ) { + JdbcTemplate runtimeJdbc = context.getBean(JdbcTemplate.class); + assertThat(runtimeJdbc.queryForObject("SELECT CURRENT_USER", String.class)) + .isEqualTo(runtimeRole); + Map attributes = migrationJdbc.queryForMap( + """ + SELECT rolsuper, rolbypassrls, rolinherit, rolcreatedb, rolcreaterole + FROM pg_catalog.pg_roles + WHERE rolname = ? + """, + runtimeRole + ); + assertThat(attributes) + .containsEntry("rolsuper", false) + .containsEntry("rolbypassrls", false) + .containsEntry("rolinherit", false) + .containsEntry("rolcreatedb", false) + .containsEntry("rolcreaterole", false); + List ownedTables = migrationJdbc.queryForList( + """ + SELECT relation.relname + FROM pg_catalog.pg_class relation + JOIN pg_catalog.pg_namespace namespace + ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = 'public' + AND relation.relkind IN ('r', 'p') + AND pg_catalog.pg_get_userbyid(relation.relowner) = ? + """, + String.class, + runtimeRole + ); + assertThat(ownedTables).isEmpty(); + Integer disabledPolicyTables = migrationJdbc.queryForObject( + """ + SELECT COUNT(*) + FROM ( + SELECT DISTINCT tablename + FROM pg_catalog.pg_policies + WHERE schemaname = 'public' + ) policies + JOIN pg_catalog.pg_class relation + ON relation.oid = ('public.' || policies.tablename)::regclass + WHERE NOT relation.relrowsecurity + """, + Integer.class + ); + assertThat(disabledPolicyTables).isZero(); + for (String table : SEED_TABLES) { + for (String privilege : List.of("SELECT", "INSERT", "UPDATE")) { + assertThat(runtimeJdbc.queryForObject( + "SELECT pg_catalog.has_table_privilege(CURRENT_USER, ?, ?)", + Boolean.class, + "public." + table, + privilege + )).as("restricted runtime %s privilege for %s", privilege, table).isTrue(); + } + assertThat(runtimeJdbc.queryForObject( + "SELECT pg_catalog.has_table_privilege(CURRENT_USER, ?, 'DELETE')", + Boolean.class, + "public." + table + )).as("restricted runtime must not delete from %s", table).isFalse(); + } + for (String table : RUNTIME_READ_TABLES) { + assertThat(runtimeJdbc.queryForObject( + "SELECT pg_catalog.has_table_privilege(CURRENT_USER, ?, 'SELECT')", + Boolean.class, + "public." + table + )).as("restricted runtime SELECT privilege for %s", table).isTrue(); + for (String privilege : List.of("INSERT", "UPDATE", "DELETE")) { + assertThat(runtimeJdbc.queryForObject( + "SELECT pg_catalog.has_table_privilege(CURRENT_USER, ?, ?)", + Boolean.class, + "public." + table, + privilege + )).as("restricted runtime must not have %s on %s", privilege, table).isFalse(); + } + } + assertThat(runtimeJdbc.queryForObject( + "SELECT COUNT(*) FROM company", + Integer.class + )).isZero(); + + TenantTransactionExecutor transactionExecutor = + context.getBean(TenantTransactionExecutor.class); + assertTenantCounts(transactionExecutor, runtimeJdbc, COMPANY_ID, 20, 28, 21); + assertTenantCounts(transactionExecutor, runtimeJdbc, TEST_COMPANY_ID, 3, 5, 3); + + assertThat(runtimeJdbc.queryForObject( + "SELECT COUNT(*) FROM company", + Integer.class + )).isZero(); + assertThat(runtimeJdbc.queryForObject( + "SELECT NULLIF(pg_catalog.current_setting('app.company_id', true), '')", + String.class + )).isNull(); + } + + private void assertTenantCounts( + TenantTransactionExecutor transactionExecutor, + JdbcTemplate runtimeJdbc, + UUID companyId, + int userCount, + int workerCount, + int taskCount + ) { + AtomicReference> counts = new AtomicReference<>(); + transactionExecutor.execute(companyId, () -> counts.set(Map.of( + "company", runtimeJdbc.queryForObject( + "SELECT COUNT(*) FROM company", + Integer.class + ), + "user", runtimeJdbc.queryForObject( + "SELECT COUNT(*) FROM user_account", + Integer.class + ), + "worker", runtimeJdbc.queryForObject( + "SELECT COUNT(*) FROM worker", + Integer.class + ), + "task", runtimeJdbc.queryForObject( + "SELECT COUNT(*) FROM task", + Integer.class + ) + ))); + assertThat(counts).hasValue(Map.of( + "company", 1, + "user", userCount, + "worker", workerCount, + "task", taskCount + )); + } + + private Map fileSnapshot() throws Exception { + if (!Files.exists(fileStoragePath)) { + return Map.of(); + } + Map snapshot = new LinkedHashMap<>(); + try (var paths = Files.walk(fileStoragePath)) { + for (Path path : paths.filter(Files::isRegularFile).sorted().toList()) { + byte[] content = Files.readAllBytes(path); + snapshot.put( + fileStoragePath.relativize(path).toString(), + content.length + ":" + sha256(content) + ); + } + } + assertThat(snapshot).isNotEmpty(); + return Map.copyOf(snapshot); + } + + private String sha256(byte[] content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 must be available", exception); + } + } + + private String quoteIdentifier(String value) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + private String quoteLiteral(String value) { + return "'" + value.replace("'", "''") + "'"; + } + private ConfigurableApplicationContext startApplication( String url, - String username, - String password + String migrationUsername, + String migrationPassword, + String runtimeUsername, + String runtimePassword ) { Map properties = new LinkedHashMap<>(); properties.put("spring.datasource.url", url); - properties.put("spring.datasource.username", username); - properties.put("spring.datasource.password", password); + properties.put("spring.datasource.username", runtimeUsername); + properties.put("spring.datasource.password", runtimePassword); properties.put("spring.datasource.driver-class-name", "org.postgresql.Driver"); - properties.put("spring.datasource.hikari.maximum-pool-size", "2"); - properties.put("spring.datasource.hikari.minimum-idle", "0"); + properties.put("spring.datasource.hikari.maximum-pool-size", "1"); + properties.put("spring.datasource.hikari.minimum-idle", "1"); + properties.put("spring.datasource.hikari.pool-name", "demo-seed-restricted-runtime-pool"); properties.put("spring.flyway.url", url); - properties.put("spring.flyway.user", username); - properties.put("spring.flyway.password", password); + properties.put("spring.flyway.user", migrationUsername); + properties.put("spring.flyway.password", migrationPassword); properties.put( "spring.flyway.locations", "classpath:db/migration,classpath:db/migration-postgresql" @@ -287,8 +715,17 @@ private SeedSnapshot snapshot(JdbcTemplate jdbcTemplate) { EXPECTED_COUNTS.forEach((table, expected) -> { int actual = countByCompany(jdbcTemplate, table, COMPANY_ID); assertThat(actual).as("%s count", table).isEqualTo(expected); - counts.put(table, actual); + counts.put("demo:" + table, actual); + }); + EXPECTED_TEST_COUNTS.forEach((table, expected) -> { + int actual = countByCompany(jdbcTemplate, table, TEST_COMPANY_ID); + assertThat(actual).as("test %s count", table).isEqualTo(expected); + counts.put("test:" + table, actual); }); + assertThat(countByCompany(jdbcTemplate, "company", COMPANY_ID)).isEqualTo(1); + assertThat(countByCompany(jdbcTemplate, "company", TEST_COMPANY_ID)).isEqualTo(1); + assertThat(countByCompany(jdbcTemplate, "company_settings", COMPANY_ID)).isEqualTo(1); + assertThat(countByCompany(jdbcTemplate, "company_settings", TEST_COMPANY_ID)).isEqualTo(1); Timestamp showcaseCreatedAt = jdbcTemplate.queryForObject( "SELECT created_at FROM workflow_case WHERE case_id = ? AND company_id = ?", Timestamp.class, @@ -296,7 +733,42 @@ private SeedSnapshot snapshot(JdbcTemplate jdbcTemplate) { COMPANY_ID ); assertThat(showcaseCreatedAt).isNotNull(); - return new SeedSnapshot(Map.copyOf(counts), showcaseCreatedAt); + Map> rows = new LinkedHashMap<>(); + for (String table : SEED_TABLES) { + rows.put(table, seedRows(jdbcTemplate, table)); + } + return new SeedSnapshot(Map.copyOf(counts), showcaseCreatedAt, Map.copyOf(rows)); + } + + private List seedRows(JdbcTemplate jdbcTemplate, String table) { + if ("document_request_draft_type".equals(table)) { + return jdbcTemplate.queryForList( + """ + SELECT to_jsonb(seed_row)::text + FROM public.document_request_draft_type AS seed_row + JOIN public.document_request_draft AS draft + ON draft.draft_id = seed_row.draft_id + WHERE draft.company_id IN (?, ?) + ORDER BY 1 + """, + String.class, + COMPANY_ID, + TEST_COMPANY_ID + ); + } + return jdbcTemplate.queryForList( + "SELECT (to_jsonb(seed_row) - 'password_hash')::text " + + "FROM public." + quoteIdentifier(table) + " AS seed_row " + + "WHERE company_id IN (?, ?) ORDER BY 1", + String.class, + COMPANY_ID, + TEST_COMPANY_ID + ); + } + + private void assertSameStructure(SeedSnapshot actual, SeedSnapshot expected) { + assertThat(actual.counts()).isEqualTo(expected.counts()); + assertThat(actual.showcaseCreatedAt()).isEqualTo(expected.showcaseCreatedAt()); } private int countByCompany(JdbcTemplate jdbcTemplate, String table, UUID companyId) { @@ -316,6 +788,10 @@ private String requiredEnvironmentVariable(String name) { return value; } - private record SeedSnapshot(Map counts, Timestamp showcaseCreatedAt) { + private record SeedSnapshot( + Map counts, + Timestamp showcaseCreatedAt, + Map> rows + ) { } } diff --git a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java index ea92a720..2cd3d2f2 100644 --- a/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java +++ b/src/test/java/com/fowoco/server/worker/infrastructure/seed/DemoWorkerSeedRunnerTest.java @@ -1,10 +1,15 @@ package com.fowoco.server.worker.infrastructure.seed; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import com.fowoco.server.auth.infrastructure.seed.DemoAuthSeedProperties; import com.fowoco.server.company.application.port.CompanyRepository; import com.fowoco.server.company.domain.Company; +import com.fowoco.server.common.security.TenantTransactionExecutor; import com.fowoco.server.worker.application.WorkerSearchQuery; import com.fowoco.server.worker.application.port.WorkerRepository; import com.fowoco.server.worker.domain.Worker; @@ -64,11 +69,13 @@ void createsOperationalDemoWorkersAndKeepsTestCompanySmall() throws Exception { companyRepository.insert(Company.create(TEST_COMPANY_ID, "FOWOCO Test Company", NOW)); InMemoryWorkerRepository workerRepository = new InMemoryWorkerRepository(); MutableClock clock = new MutableClock(NOW, ZoneOffset.UTC); + TenantTransactionExecutor transactionExecutor = immediateTenantTransactionExecutor(); DemoWorkerSeedRunner runner = new DemoWorkerSeedRunner( properties(), companyRepository, workerRepository, - clock + clock, + transactionExecutor ); runner.run(new DefaultApplicationArguments(new String[0])); @@ -122,6 +129,19 @@ void createsOperationalDemoWorkersAndKeepsTestCompanySmall() throws Exception { assertThat(workerRepository.workers.get( UUID.fromString("92000000-0000-0000-0000-000000000025") ).stayExpiryDate()).isNull(); + verify(transactionExecutor, org.mockito.Mockito.times(3)) + .execute(org.mockito.ArgumentMatchers.eq(DEMO_COMPANY_ID), any(Runnable.class)); + verify(transactionExecutor, org.mockito.Mockito.times(3)) + .execute(org.mockito.ArgumentMatchers.eq(TEST_COMPANY_ID), any(Runnable.class)); + } + + private TenantTransactionExecutor immediateTenantTransactionExecutor() { + TenantTransactionExecutor executor = mock(TenantTransactionExecutor.class); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(executor).execute(any(UUID.class), any(Runnable.class)); + return executor; } private DemoAuthSeedProperties properties() {