diff --git a/doc/ideas/login-credentials-data-model.mermaid b/doc/ideas/login-credentials-data-model.mermaid index 45722bf2..5d811421 100644 --- a/doc/ideas/login-credentials-data-model.mermaid +++ b/doc/ideas/login-credentials-data-model.mermaid @@ -1,13 +1,13 @@ classDiagram direction LR - OfficePerson o.. "*" LoginCredentials - LoginCredentials "1" o-- "1" RbacSubject + OfficePerson o.. "*" Credentials + Credentials "1" o-- "1" RbacSubject - LoginContext "1..n" --o "1" LoginContextMapping - LoginCredentials "1..n" --o "1" LoginContextMapping + CredentialsContext "1..n" --o "1" CredentialsContextMapping + Credentials "1..n" --o "1" CredentialsContextMapping - class LoginCredentials{ + class Credentials{ +twoFactorAuth: text +telephonePassword: text +emailAdress: text @@ -18,14 +18,14 @@ classDiagram -onboardingToken: text [w/o] } - class LoginContext{ + class CredentialsContext{ -type: Enum [SSH, Matrix, Mastodon, ...] -qualifier: text } - class LoginContextMapping{ + class CredentialsContextMapping{ } - note for LoginContextMapping "Assigns LoginCredentials to LoginContexts" + note for CredentialsContextMapping "Assigns Credentials to CredentialsContexts" class RbacSubject{ +uuid: uuid @@ -41,9 +41,9 @@ classDiagram +salutation: text } - style LoginContext fill:#00f,color:#fff - style LoginContextMapping fill:#00f,color:#fff - style LoginCredentials fill:#00f,color:#fff + style CredentialsContext fill:#00f,color:#fff + style CredentialsContextMapping fill:#00f,color:#fff + style Credentials fill:#00f,color:#fff style RbacSubject fill:#f96,color:#fff style OfficePerson fill:#f66,color:#000 diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsController.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsController.java index cc9e1249..bedcb441 100644 --- a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsController.java +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsContextsController.java @@ -2,16 +2,19 @@ package net.hostsharing.hsadminng.credentials; import java.util.List; +import io.micrometer.core.annotation.Timed; +import net.hostsharing.hsadminng.config.NoSecurityRequirement; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.credentials.generated.api.v1.api.LoginContextsApi; -import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource; +import net.hostsharing.hsadminng.credentials.generated.api.v1.api.ContextsApi; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.ContextResource; import net.hostsharing.hsadminng.mapper.StrictMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RestController; @RestController -public class HsCredentialsContextsController implements LoginContextsApi { +@NoSecurityRequirement +public class HsCredentialsContextsController implements ContextsApi { @Autowired private Context context; @@ -23,11 +26,12 @@ public class HsCredentialsContextsController implements LoginContextsApi { private HsCredentialsContextRbacRepository contextRepo; @Override - public ResponseEntity> getListOfLoginContexts(final String assumedRoles) { + @Timed("app.credentials.contexts.getListOfLoginContexts") + public ResponseEntity> getListOfContexts(final String assumedRoles) { context.assumeRoles(assumedRoles); final var loginContexts = contextRepo.findAll(); - final var result = mapper.mapList(loginContexts, LoginContextResource.class); + final var result = mapper.mapList(loginContexts, ContextResource.class); return ResponseEntity.ok(result); } } diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsController.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsController.java index 62f55dc7..fef10830 100644 --- a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsController.java +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsController.java @@ -2,11 +2,15 @@ package net.hostsharing.hsadminng.credentials; import java.util.List; import java.util.UUID; + +import io.micrometer.core.annotation.Timed; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import net.hostsharing.hsadminng.config.MessageTranslator; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.credentials.generated.api.v1.api.LoginCredentialsApi; -import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsInsertResource; -import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource; -import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsResource; +import net.hostsharing.hsadminng.credentials.generated.api.v1.api.CredentialsApi; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsInsertResource; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsPatchResource; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsResource; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository; import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; @@ -14,8 +18,11 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RestController; +import jakarta.persistence.EntityNotFoundException; + @RestController -public class HsCredentialsController implements LoginCredentialsApi { +@SecurityRequirement(name = "casTicket") +public class HsCredentialsController implements CredentialsApi { @Autowired private Context context; @@ -26,71 +33,84 @@ public class HsCredentialsController implements LoginCredentialsApi { @Autowired private StrictMapper mapper; + @Autowired + private MessageTranslator messageTranslator; + @Autowired private HsOfficePersonRbacRepository personRepo; @Autowired - private HsCredentialsRepository loginCredentialsRepo; + private HsCredentialsRepository credentialsRepo; @Override - public ResponseEntity getSingleLoginCredentialsByUuid( + @Timed("app.credentials.credentials.getSingleCredentialsByUuid") + public ResponseEntity getSingleCredentialsByUuid( final String assumedRoles, - final UUID loginCredentialsUuid) { + final UUID credentialsUuid) { context.assumeRoles(assumedRoles); - final var credentials = loginCredentialsRepo.findByUuid(loginCredentialsUuid); - final var result = mapper.map(credentials, LoginCredentialsResource.class); + final var credentials = credentialsRepo.findByUuid(credentialsUuid); + final var result = mapper.map(credentials, CredentialsResource.class); return ResponseEntity.ok(result); } @Override - public ResponseEntity> getListOfLoginCredentialsByPersonUuid( + @Timed("app.credentials.credentials.getListOfCredentialsByPersonUuid") + public ResponseEntity> getListOfCredentialsByPersonUuid( final String assumedRoles, final UUID personUuid ) { context.assumeRoles(assumedRoles); - final var person = personRepo.findByUuid(personUuid).orElseThrow(); // FIXME: use proper exception - final var credentials = loginCredentialsRepo.findByPerson(person); - final var result = mapper.mapList(credentials, LoginCredentialsResource.class); + final var person = personRepo.findByUuid(personUuid).orElseThrow( + () -> new EntityNotFoundException( + messageTranslator.translate("{0} \"{1}\" not found or not accessible", "personUuid", personUuid) + ) + + ); // FIXME: use proper exception + final var credentials = credentialsRepo.findByPerson(person); + final var result = mapper.mapList(credentials, CredentialsResource.class); return ResponseEntity.ok(result); } @Override - public ResponseEntity postNewLoginCredentials( + @Timed("app.credentials.credentials.postNewCredentials") + public ResponseEntity postNewCredentials( final String assumedRoles, - final LoginCredentialsInsertResource body + final CredentialsInsertResource body ) { context.assumeRoles(assumedRoles); - final var newLoginCredentialsEntity = mapper.map(body, HsCredentialsEntity.class); - final var savedLoginCredentialsEntity = loginCredentialsRepo.save(newLoginCredentialsEntity); - final var newLoginCredentialsResource = mapper.map(savedLoginCredentialsEntity, LoginCredentialsResource.class); - return ResponseEntity.ok(newLoginCredentialsResource); + final var newCredentialsEntity = mapper.map(body, HsCredentialsEntity.class); + final var savedCredentialsEntity = credentialsRepo.save(newCredentialsEntity); + final var newCredentialsResource = mapper.map(savedCredentialsEntity, CredentialsResource.class); + return ResponseEntity.ok(newCredentialsResource); } @Override - public ResponseEntity deleteLoginCredentialsByUuid(final String assumedRoles, final UUID loginCredentialsUuid) { + @Timed("app.credentials.credentials.deleteCredentialsByUuid") + public ResponseEntity deleteCredentialsByUuid(final String assumedRoles, final UUID credentialsUuid) { context.assumeRoles(assumedRoles); - final var loginCredentialsEntity = em.getReference(HsCredentialsEntity.class, loginCredentialsUuid); - em.remove(loginCredentialsEntity); + final var credentialsEntity = em.getReference(HsCredentialsEntity.class, credentialsUuid); + em.remove(credentialsEntity); return ResponseEntity.noContent().build(); } @Override - public ResponseEntity patchLoginCredentials( + @Timed("app.credentials.credentials.patchCredentials") + public ResponseEntity patchCredentials( final String assumedRoles, - final UUID loginCredentialsUuid, - final LoginCredentialsPatchResource body + final UUID credentialsUuid, + final CredentialsPatchResource body ) { context.assumeRoles(assumedRoles); - final var current = loginCredentialsRepo.findByUuid(loginCredentialsUuid).orElseThrow(); + final var current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow(); - new HsCredentialsEntityPatcher(em, current).apply(body); + new HsCredentialsEntityPatcher(em, messageTranslator, current).apply(body); - final var saved = loginCredentialsRepo.save(current); - final var mapped = mapper.map(saved, LoginCredentialsResource.class); + final var saved = credentialsRepo.save(current); + final var mapped = mapper.map(saved, CredentialsResource.class); return ResponseEntity.ok(mapped); } } diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntity.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntity.java index fad26941..cac48352 100644 --- a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntity.java @@ -26,7 +26,7 @@ import static net.hostsharing.hsadminng.repr.Stringify.stringify; @AllArgsConstructor public class HsCredentialsEntity implements BaseEntity, Stringifyable { - protected static Stringify stringify = stringify(HsCredentialsEntity.class, "loginCredentials") + protected static Stringify stringify = stringify(HsCredentialsEntity.class, "credentials") .withProp(HsCredentialsEntity::isActive) .withProp(HsCredentialsEntity::getEmailAddress) .withProp(HsCredentialsEntity::getTwoFactorAuth) diff --git a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcher.java index 935cc1d8..44f1ccd2 100644 --- a/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcher.java +++ b/src/main/java/net/hostsharing/hsadminng/credentials/HsCredentialsEntityPatcher.java @@ -1,7 +1,8 @@ package net.hostsharing.hsadminng.credentials; -import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource; -import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource; +import net.hostsharing.hsadminng.config.MessageTranslator; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.ContextResource; +import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsPatchResource; import net.hostsharing.hsadminng.mapper.EntityPatcher; import net.hostsharing.hsadminng.mapper.OptionalFromJson; @@ -11,18 +12,20 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; -public class HsCredentialsEntityPatcher implements EntityPatcher { +public class HsCredentialsEntityPatcher implements EntityPatcher { private final EntityManager em; + private MessageTranslator messageTranslator; private final HsCredentialsEntity entity; - public HsCredentialsEntityPatcher(final EntityManager em, final HsCredentialsEntity entity) { + public HsCredentialsEntityPatcher(final EntityManager em, MessageTranslator messageTranslator, final HsCredentialsEntity entity) { this.em = em; + this.messageTranslator = messageTranslator; this.entity = entity; } @Override - public void apply(final LoginCredentialsPatchResource resource) { + public void apply(final CredentialsPatchResource resource) { if ( resource.getActive() != null ) { entity.setActive(resource.getActive()); } @@ -40,11 +43,11 @@ public class HsCredentialsEntityPatcher implements EntityPatcher resources, + List resources, Set entities ) { final var resourceUuids = resources.stream() - .map(LoginContextResource::getUuid) + .map(ContextResource::getUuid) .collect(Collectors.toSet()); final var entityUuids = entities.stream() @@ -57,14 +60,15 @@ public class HsCredentialsEntityPatcher implements EntityPatcher { @@ -58,8 +59,8 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase< .qualifier("dev") .build(); - private LoginContextResource patchContextResource2; - private LoginContextResource patchContextResource3; + private ContextResource patchContextResource2; + private ContextResource patchContextResource3; // This is what em.find should return for CONTEXT_UUID_3 private final HsCredentialsContextRealEntity newContextEntity3 = HsCredentialsContextRealEntity.builder() @@ -69,7 +70,7 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase< .build(); private final Set initialContextEntities = Set.of(initialContextEntity1, initialContextEntity2); - private List patchedContextResources; + private List patchedContextResources; private final Set expectedPatchedContextEntities = Set.of(initialContextEntity2, newContextEntity3); @Mock @@ -82,14 +83,14 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase< lenient().when(em.find(eq(HsCredentialsContextRealEntity.class), eq(CONTEXT_UUID_2))).thenReturn(initialContextEntity2); lenient().when(em.find(eq(HsCredentialsContextRealEntity.class), eq(CONTEXT_UUID_3))).thenReturn(newContextEntity3); - patchContextResource2 = new LoginContextResource(); + patchContextResource2 = new ContextResource(); patchContextResource2.setUuid(CONTEXT_UUID_2); - patchContextResource2.setType(LoginContextTypeResource.SSH); + patchContextResource2.setType("SSH"); patchContextResource2.setQualifier("dev"); - patchContextResource3 = new LoginContextResource(); + patchContextResource3 = new ContextResource(); patchContextResource3.setUuid(CONTEXT_UUID_3); - patchContextResource3.setType(LoginContextTypeResource.HSADMIN); + patchContextResource3.setType("HSADMIN"); patchContextResource3.setQualifier("test"); patchedContextResources = List.of(patchContextResource2, patchContextResource3); @@ -110,13 +111,13 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase< } @Override - protected LoginCredentialsPatchResource newPatchResource() { - return new LoginCredentialsPatchResource(); + protected CredentialsPatchResource newPatchResource() { + return new CredentialsPatchResource(); } @Override protected HsCredentialsEntityPatcher createPatcher(final HsCredentialsEntity entity) { - return new HsCredentialsEntityPatcher(em, entity); + return new HsCredentialsEntityPatcher(em, mock(MessageTranslator.class), entity); } @Override @@ -124,38 +125,38 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase< return Stream.of( new SimpleProperty<>( "active", - LoginCredentialsPatchResource::setActive, + CredentialsPatchResource::setActive, PATCHED_ACTIVE, HsCredentialsEntity::setActive, PATCHED_ACTIVE) .notNullable(), new JsonNullableProperty<>( "emailAddress", - LoginCredentialsPatchResource::setEmailAddress, + CredentialsPatchResource::setEmailAddress, PATCHED_EMAIL_ADDRESS, HsCredentialsEntity::setEmailAddress, PATCHED_EMAIL_ADDRESS), new JsonNullableProperty<>( "twoFactorAuth", - LoginCredentialsPatchResource::setTwoFactorAuth, + CredentialsPatchResource::setTwoFactorAuth, PATCHED_TWO_FACTOR_AUTH, HsCredentialsEntity::setTwoFactorAuth, PATCHED_TWO_FACTOR_AUTH), new JsonNullableProperty<>( "smsNumber", - LoginCredentialsPatchResource::setSmsNumber, + CredentialsPatchResource::setSmsNumber, PATCHED_SMS_NUMBER, HsCredentialsEntity::setSmsNumber, PATCHED_SMS_NUMBER), new JsonNullableProperty<>( "phonePassword", - LoginCredentialsPatchResource::setPhonePassword, + CredentialsPatchResource::setPhonePassword, PATCHED_PHONE_PASSWORD, HsCredentialsEntity::setPhonePassword, PATCHED_PHONE_PASSWORD), new SimpleProperty<>( "contexts", - LoginCredentialsPatchResource::setContexts, + CredentialsPatchResource::setContexts, patchedContextResources, HsCredentialsEntity::setLoginContexts, expectedPatchedContextEntities) diff --git a/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsRepositoryIntegrationTest.java index c9daa72f..96c8571d 100644 --- a/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/credentials/HsCredentialsRepositoryIntegrationTest.java @@ -42,7 +42,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { HttpServletRequest request; @Autowired - private HsCredentialsRepository loginCredentialsRepository; + private HsCredentialsRepository credentialsRepository; @Autowired private HsCredentialsContextRealRepository loginContextRealRepo; @@ -88,7 +88,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { @Test void shouldFindByUuidUsingTestData() { // when - final var foundEntityOptional = loginCredentialsRepository.findByUuid(alexSubject.getUuid()); + final var foundEntityOptional = credentialsRepository.findByUuid(alexSubject.getUuid()); // then assertThat(foundEntityOptional).isPresent() @@ -96,7 +96,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { } @Test - void shouldSaveLoginCredentialsWithExistingContext() { + void shouldSaveCredentialsWithExistingContext() { // given final var existingContext = loginContextRealRepo.findByTypeAndQualifier("HSADMIN", "prod") .orElseThrow(); @@ -111,12 +111,12 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { .build(); // when - loginCredentialsRepository.save(newCredentials); + credentialsRepository.save(newCredentials); em.flush(); em.clear(); // then - final var foundEntityOptional = loginCredentialsRepository.findByUuid(drewSubject.getUuid()); + final var foundEntityOptional = credentialsRepository.findByUuid(drewSubject.getUuid()); assertThat(foundEntityOptional).isPresent(); final var foundEntity = foundEntityOptional.get(); assertThat(foundEntity.getEmailAddress()).isEqualTo("drew.new@example.com"); @@ -129,7 +129,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { } @Test - void shouldNotSaveLoginCredentialsWithNewContext() { + void shouldNotSaveCredentialsWithNewContext() { // given final var newContext = HsCredentialsContextRealEntity.builder() .type("MATRIX") @@ -146,7 +146,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { // when final var exception = catchThrowable(() -> { - loginCredentialsRepository.save(newCredentials); + credentialsRepository.save(newCredentials); em.flush(); }); @@ -155,7 +155,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { } @Test - void shouldSaveNewLoginCredentialsWithoutContext() { + void shouldSaveNewCredentialsWithoutContext() { // given final var newCredentials = HsCredentialsEntity.builder() .subject(testUserSubject) @@ -167,12 +167,12 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { .build(); // when - loginCredentialsRepository.save(newCredentials); + credentialsRepository.save(newCredentials); em.flush(); em.clear(); // then - final var foundEntityOptional = loginCredentialsRepository.findByUuid(testUserSubject.getUuid()); + final var foundEntityOptional = credentialsRepository.findByUuid(testUserSubject.getUuid()); assertThat(foundEntityOptional).isPresent(); final var foundEntity = foundEntityOptional.get(); assertThat(foundEntity.getEmailAddress()).isEqualTo("test.user.new@example.com"); @@ -183,21 +183,21 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest { } @Test - void shouldUpdateExistingLoginCredentials() { + void shouldUpdateExistingCredentials() { // given - final var entityToUpdate = loginCredentialsRepository.findByUuid(alexSubject.getUuid()).orElseThrow(); + final var entityToUpdate = credentialsRepository.findByUuid(alexSubject.getUuid()).orElseThrow(); final var initialVersion = entityToUpdate.getVersion(); // when entityToUpdate.setActive(false); entityToUpdate.setEmailAddress("updated.user1@example.com"); - final var savedEntity = loginCredentialsRepository.save(entityToUpdate); + final var savedEntity = credentialsRepository.save(entityToUpdate); em.flush(); em.clear(); // then assertThat(savedEntity.getVersion()).isGreaterThan(initialVersion); - final var updatedEntityOptional = loginCredentialsRepository.findByUuid(alexSubject.getUuid()); + final var updatedEntityOptional = credentialsRepository.findByUuid(alexSubject.getUuid()); assertThat(updatedEntityOptional).isPresent(); final var updatedEntity = updatedEntityOptional.get(); assertThat(updatedEntity.isActive()).isFalse(); diff --git a/src/test/java/net/hostsharing/hsadminng/journal/TransactionContextIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/journal/TransactionContextIntegrationTest.java new file mode 100644 index 00000000..abd0d764 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/journal/TransactionContextIntegrationTest.java @@ -0,0 +1,122 @@ +package net.hostsharing.hsadminng.journal; + +import lombok.SneakyThrows; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import net.hostsharing.hsadminng.rbac.test.cust.TestCustomerEntity; +import net.hostsharing.hsadminng.rbac.test.cust.TestCustomerRepository; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.transaction.annotation.Propagation.NEVER; + +@DataJpaTest +@Import({ Context.class, JpaAttempt.class }) +@Tag("generalIntegrationTest") +class TransactionContextIntegrationTest extends ContextBasedTestWithCleanup { + + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + JpaAttempt jpaAttempt; + + @MockitoBean + HttpServletRequest request; + + @Autowired + private TestCustomerRepository repository; + + @Test + @Transactional(propagation = NEVER) + void testConcurrentCommitOrder() { + + // determine initial row count + final var rowCount = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return em.createQuery("SELECT e FROM TestCustomerEntity e", TestCustomerEntity.class).getResultList(); + }).assertSuccessful().returnedValue().size(); + + // when 3 transactions with different runtime run concurrently + runThreads( + // starts first, ends last (because it's slow) + createTransactionThread("t01", 91001, 500), + + // starts second, ends first (because it's faster than the one that got started first) + createTransactionThread("t02", 91002, 0), + + // starts third, ends second + createTransactionThread("t03", 91003, 100) + ); + + // then all 3 threads did insert one row each + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + var all = em.createQuery("SELECT e FROM TestCustomerEntity e", TestCustomerEntity.class).getResultList(); + assertThat(all).hasSize(rowCount + 3); + }).assertSuccessful(); + + // and seqTxId order is in correct order + final var txContextsX = em.createNativeQuery( + "select concat(c.txId, ':', c.currentTask) from base.tx_context c order by c.seqTxId" + ).getResultList(); + final var txContextTasks = last(3, txContextsX).stream().map(Object::toString).toList(); + assertThat(txContextTasks.get(0)).endsWith( + ":TestCustomerEntity(uuid=null, version=0, prefix=t02, reference=91002, adminUserName=null)"); + assertThat(txContextTasks.get(1)).endsWith( + "TestCustomerEntity(uuid=null, version=0, prefix=t03, reference=91003, adminUserName=null)"); + assertThat(txContextTasks.get(2)).endsWith( + "TestCustomerEntity(uuid=null, version=0, prefix=t01, reference=91001, adminUserName=null)"); + } + + private @NotNull Thread createTransactionThread(final String t01, final int reference, final int millis) { + return new Thread(() -> { + jpaAttempt.transacted(() -> { + final var entity1 = new TestCustomerEntity(); + entity1.setPrefix(t01); + entity1.setReference(reference); + + context.define(entity1.toString(), null, "superuser-alex@hostsharing.net", null); + entity1.setReference(80000 + toInt(em.createNativeQuery("SELECT txid_current()").getSingleResult())); + repository.save(entity1); + sleep(millis); // simulate a delay + }).assertSuccessful(); + }); + } + + private int toInt(final Object singleResult) { + return ((Long)singleResult).intValue(); + } + + @SneakyThrows + private void sleep(final int millis) { + Thread.sleep(millis); + } + + @SneakyThrows + private void runThreads(final Thread... threads) { + for (final Thread thread : threads) { + thread.start(); + sleep(100); + } + for (final Thread thread : threads) { + thread.join(); + } + + } + private List last(final int n, final List list) { + return list.subList(Math.max(list.size() - n, 0), list.size()); + } +}