1
0

sequential transaction-id (#178)

Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/178
This commit is contained in:
Michael Hoennig
2025-05-21 11:45:04 +02:00
15 changed files with 297 additions and 103 deletions

View File

@@ -1,13 +1,13 @@
classDiagram classDiagram
direction LR direction LR
OfficePerson o.. "*" LoginCredentials OfficePerson o.. "*" Credentials
LoginCredentials "1" o-- "1" RbacSubject Credentials "1" o-- "1" RbacSubject
LoginContext "1..n" --o "1" LoginContextMapping CredentialsContext "1..n" --o "1" CredentialsContextMapping
LoginCredentials "1..n" --o "1" LoginContextMapping Credentials "1..n" --o "1" CredentialsContextMapping
class LoginCredentials{ class Credentials{
+twoFactorAuth: text +twoFactorAuth: text
+telephonePassword: text +telephonePassword: text
+emailAdress: text +emailAdress: text
@@ -18,14 +18,14 @@ classDiagram
-onboardingToken: text [w/o] -onboardingToken: text [w/o]
} }
class LoginContext{ class CredentialsContext{
-type: Enum [SSH, Matrix, Mastodon, ...] -type: Enum [SSH, Matrix, Mastodon, ...]
-qualifier: text -qualifier: text
} }
class LoginContextMapping{ class CredentialsContextMapping{
} }
note for LoginContextMapping "Assigns LoginCredentials to LoginContexts" note for CredentialsContextMapping "Assigns Credentials to CredentialsContexts"
class RbacSubject{ class RbacSubject{
+uuid: uuid +uuid: uuid
@@ -41,9 +41,9 @@ classDiagram
+salutation: text +salutation: text
} }
style LoginContext fill:#00f,color:#fff style CredentialsContext fill:#00f,color:#fff
style LoginContextMapping fill:#00f,color:#fff style CredentialsContextMapping fill:#00f,color:#fff
style LoginCredentials fill:#00f,color:#fff style Credentials fill:#00f,color:#fff
style RbacSubject fill:#f96,color:#fff style RbacSubject fill:#f96,color:#fff
style OfficePerson fill:#f66,color:#000 style OfficePerson fill:#f66,color:#000

View File

@@ -2,16 +2,19 @@ package net.hostsharing.hsadminng.credentials;
import java.util.List; 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.context.Context;
import net.hostsharing.hsadminng.credentials.generated.api.v1.api.LoginContextsApi; import net.hostsharing.hsadminng.credentials.generated.api.v1.api.ContextsApi;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource; import net.hostsharing.hsadminng.credentials.generated.api.v1.model.ContextResource;
import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.mapper.StrictMapper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
public class HsCredentialsContextsController implements LoginContextsApi { @NoSecurityRequirement
public class HsCredentialsContextsController implements ContextsApi {
@Autowired @Autowired
private Context context; private Context context;
@@ -23,11 +26,12 @@ public class HsCredentialsContextsController implements LoginContextsApi {
private HsCredentialsContextRbacRepository contextRepo; private HsCredentialsContextRbacRepository contextRepo;
@Override @Override
public ResponseEntity<List<LoginContextResource>> getListOfLoginContexts(final String assumedRoles) { @Timed("app.credentials.contexts.getListOfLoginContexts")
public ResponseEntity<List<ContextResource>> getListOfContexts(final String assumedRoles) {
context.assumeRoles(assumedRoles); context.assumeRoles(assumedRoles);
final var loginContexts = contextRepo.findAll(); 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); return ResponseEntity.ok(result);
} }
} }

View File

@@ -2,11 +2,15 @@ package net.hostsharing.hsadminng.credentials;
import java.util.List; import java.util.List;
import java.util.UUID; 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.context.Context;
import net.hostsharing.hsadminng.credentials.generated.api.v1.api.LoginCredentialsApi; import net.hostsharing.hsadminng.credentials.generated.api.v1.api.CredentialsApi;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsInsertResource; import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsInsertResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource; import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsPatchResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsResource; import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsResource;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository;
import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; 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.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import jakarta.persistence.EntityNotFoundException;
@RestController @RestController
public class HsCredentialsController implements LoginCredentialsApi { @SecurityRequirement(name = "casTicket")
public class HsCredentialsController implements CredentialsApi {
@Autowired @Autowired
private Context context; private Context context;
@@ -26,71 +33,84 @@ public class HsCredentialsController implements LoginCredentialsApi {
@Autowired @Autowired
private StrictMapper mapper; private StrictMapper mapper;
@Autowired
private MessageTranslator messageTranslator;
@Autowired @Autowired
private HsOfficePersonRbacRepository personRepo; private HsOfficePersonRbacRepository personRepo;
@Autowired @Autowired
private HsCredentialsRepository loginCredentialsRepo; private HsCredentialsRepository credentialsRepo;
@Override @Override
public ResponseEntity<LoginCredentialsResource> getSingleLoginCredentialsByUuid( @Timed("app.credentials.credentials.getSingleCredentialsByUuid")
public ResponseEntity<CredentialsResource> getSingleCredentialsByUuid(
final String assumedRoles, final String assumedRoles,
final UUID loginCredentialsUuid) { final UUID credentialsUuid) {
context.assumeRoles(assumedRoles); context.assumeRoles(assumedRoles);
final var credentials = loginCredentialsRepo.findByUuid(loginCredentialsUuid); final var credentials = credentialsRepo.findByUuid(credentialsUuid);
final var result = mapper.map(credentials, LoginCredentialsResource.class); final var result = mapper.map(credentials, CredentialsResource.class);
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@Override @Override
public ResponseEntity<List<LoginCredentialsResource>> getListOfLoginCredentialsByPersonUuid( @Timed("app.credentials.credentials.getListOfCredentialsByPersonUuid")
public ResponseEntity<List<CredentialsResource>> getListOfCredentialsByPersonUuid(
final String assumedRoles, final String assumedRoles,
final UUID personUuid final UUID personUuid
) { ) {
context.assumeRoles(assumedRoles); context.assumeRoles(assumedRoles);
final var person = personRepo.findByUuid(personUuid).orElseThrow(); // FIXME: use proper exception final var person = personRepo.findByUuid(personUuid).orElseThrow(
final var credentials = loginCredentialsRepo.findByPerson(person); () -> new EntityNotFoundException(
final var result = mapper.mapList(credentials, LoginCredentialsResource.class); 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); return ResponseEntity.ok(result);
} }
@Override @Override
public ResponseEntity<LoginCredentialsResource> postNewLoginCredentials( @Timed("app.credentials.credentials.postNewCredentials")
public ResponseEntity<CredentialsResource> postNewCredentials(
final String assumedRoles, final String assumedRoles,
final LoginCredentialsInsertResource body final CredentialsInsertResource body
) { ) {
context.assumeRoles(assumedRoles); context.assumeRoles(assumedRoles);
final var newLoginCredentialsEntity = mapper.map(body, HsCredentialsEntity.class); final var newCredentialsEntity = mapper.map(body, HsCredentialsEntity.class);
final var savedLoginCredentialsEntity = loginCredentialsRepo.save(newLoginCredentialsEntity); final var savedCredentialsEntity = credentialsRepo.save(newCredentialsEntity);
final var newLoginCredentialsResource = mapper.map(savedLoginCredentialsEntity, LoginCredentialsResource.class); final var newCredentialsResource = mapper.map(savedCredentialsEntity, CredentialsResource.class);
return ResponseEntity.ok(newLoginCredentialsResource); return ResponseEntity.ok(newCredentialsResource);
} }
@Override @Override
public ResponseEntity<Void> deleteLoginCredentialsByUuid(final String assumedRoles, final UUID loginCredentialsUuid) { @Timed("app.credentials.credentials.deleteCredentialsByUuid")
public ResponseEntity<Void> deleteCredentialsByUuid(final String assumedRoles, final UUID credentialsUuid) {
context.assumeRoles(assumedRoles); context.assumeRoles(assumedRoles);
final var loginCredentialsEntity = em.getReference(HsCredentialsEntity.class, loginCredentialsUuid); final var credentialsEntity = em.getReference(HsCredentialsEntity.class, credentialsUuid);
em.remove(loginCredentialsEntity); em.remove(credentialsEntity);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@Override @Override
public ResponseEntity<LoginCredentialsResource> patchLoginCredentials( @Timed("app.credentials.credentials.patchCredentials")
public ResponseEntity<CredentialsResource> patchCredentials(
final String assumedRoles, final String assumedRoles,
final UUID loginCredentialsUuid, final UUID credentialsUuid,
final LoginCredentialsPatchResource body final CredentialsPatchResource body
) { ) {
context.assumeRoles(assumedRoles); 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 saved = credentialsRepo.save(current);
final var mapped = mapper.map(saved, LoginCredentialsResource.class); final var mapped = mapper.map(saved, CredentialsResource.class);
return ResponseEntity.ok(mapped); return ResponseEntity.ok(mapped);
} }
} }

View File

@@ -26,7 +26,7 @@ import static net.hostsharing.hsadminng.repr.Stringify.stringify;
@AllArgsConstructor @AllArgsConstructor
public class HsCredentialsEntity implements BaseEntity<HsCredentialsEntity>, Stringifyable { public class HsCredentialsEntity implements BaseEntity<HsCredentialsEntity>, Stringifyable {
protected static Stringify<HsCredentialsEntity> stringify = stringify(HsCredentialsEntity.class, "loginCredentials") protected static Stringify<HsCredentialsEntity> stringify = stringify(HsCredentialsEntity.class, "credentials")
.withProp(HsCredentialsEntity::isActive) .withProp(HsCredentialsEntity::isActive)
.withProp(HsCredentialsEntity::getEmailAddress) .withProp(HsCredentialsEntity::getEmailAddress)
.withProp(HsCredentialsEntity::getTwoFactorAuth) .withProp(HsCredentialsEntity::getTwoFactorAuth)

View File

@@ -1,7 +1,8 @@
package net.hostsharing.hsadminng.credentials; package net.hostsharing.hsadminng.credentials;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource; import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource; 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.EntityPatcher;
import net.hostsharing.hsadminng.mapper.OptionalFromJson; import net.hostsharing.hsadminng.mapper.OptionalFromJson;
@@ -11,18 +12,20 @@ import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class HsCredentialsEntityPatcher implements EntityPatcher<LoginCredentialsPatchResource> { public class HsCredentialsEntityPatcher implements EntityPatcher<CredentialsPatchResource> {
private final EntityManager em; private final EntityManager em;
private MessageTranslator messageTranslator;
private final HsCredentialsEntity entity; 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.em = em;
this.messageTranslator = messageTranslator;
this.entity = entity; this.entity = entity;
} }
@Override @Override
public void apply(final LoginCredentialsPatchResource resource) { public void apply(final CredentialsPatchResource resource) {
if ( resource.getActive() != null ) { if ( resource.getActive() != null ) {
entity.setActive(resource.getActive()); entity.setActive(resource.getActive());
} }
@@ -40,11 +43,11 @@ public class HsCredentialsEntityPatcher implements EntityPatcher<LoginCredential
} }
public void syncLoginContextEntities( public void syncLoginContextEntities(
List<LoginContextResource> resources, List<ContextResource> resources,
Set<HsCredentialsContextRealEntity> entities Set<HsCredentialsContextRealEntity> entities
) { ) {
final var resourceUuids = resources.stream() final var resourceUuids = resources.stream()
.map(LoginContextResource::getUuid) .map(ContextResource::getUuid)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
final var entityUuids = entities.stream() final var entityUuids = entities.stream()
@@ -57,14 +60,15 @@ public class HsCredentialsEntityPatcher implements EntityPatcher<LoginCredential
if (!entityUuids.contains(resource.getUuid())) { if (!entityUuids.contains(resource.getUuid())) {
final var existingContextEntity = em.find(HsCredentialsContextRealEntity.class, resource.getUuid()); final var existingContextEntity = em.find(HsCredentialsContextRealEntity.class, resource.getUuid());
if ( existingContextEntity == null ) { if ( existingContextEntity == null ) {
// FIXME: i18n
throw new EntityNotFoundException( throw new EntityNotFoundException(
HsCredentialsContextRealEntity.class.getName() + " with uuid " + resource.getUuid() + " not found."); messageTranslator.translate("{0} \"{1}\" not found or not accessible",
"credentials uuid", resource.getUuid()));
} }
if (!existingContextEntity.getType().equals(resource.getType().name()) && if (!existingContextEntity.getType().equals(resource.getType()) &&
!existingContextEntity.getQualifier().equals(resource.getQualifier())) { !existingContextEntity.getQualifier().equals(resource.getQualifier())) {
// FIXME: i18n throw new EntityNotFoundException(
throw new EntityNotFoundException("existing " + existingContextEntity + " does not match given resource " + resource); messageTranslator.translate("existing {0} does not match given resource {1}",
existingContextEntity, resource));
} }
entities.add(existingContextEntity); entities.add(existingContextEntity);
} }

View File

@@ -17,7 +17,7 @@ paths:
# Credentials # Credentials
/api/hs/credentials/credentials: /api/hs/credentials/credentials:
$ref: "api-paths.yaml" $ref: "credentials.yaml"
/api/hs/credentials/credentials/{credentialsUuid}: /api/hs/credentials/credentials/{credentialsUuid}:
$ref: "credentials-with-uuid.yaml" $ref: "credentials-with-uuid.yaml"

View File

@@ -28,7 +28,7 @@ components:
contexts: contexts:
type: array type: array
items: items:
$ref: '-context-schemas.yaml#/components/schemas/Context' $ref: 'context-schemas.yaml#/components/schemas/Context'
required: required:
- uuid - uuid
- active - active
@@ -55,7 +55,7 @@ components:
contexts: contexts:
type: array type: array
items: items:
$ref: '-context-schemas.yaml#/components/schemas/Context' $ref: 'context-schemas.yaml#/components/schemas/Context'
additionalProperties: false additionalProperties: false
CredentialsInsert: CredentialsInsert:
@@ -83,7 +83,7 @@ components:
contexts: contexts:
type: array type: array
items: items:
$ref: '-context-schemas.yaml#/components/schemas/Context' $ref: 'context-schemas.yaml#/components/schemas/Context'
required: required:
- uuid - uuid
- active - active

View File

@@ -34,6 +34,33 @@ create table base.tx_context
create index on base.tx_context using brin (txTimestamp); create index on base.tx_context using brin (txTimestamp);
--// --//
-- ============================================================================
--changeset michael.hoennig:audit-TX-CONTEXT-TABLE-COLUMN-SEQUENTIAL-TX-ID endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Adds a column to base.tx_context which keeps a strictly sequentially ordered tx-id.
*/
alter table base.tx_context
add column seqTxId BIGINT;
CREATE OR REPLACE FUNCTION set_next_sequential_txid()
RETURNS TRIGGER AS $$
BEGIN
LOCK TABLE base.tx_context IN EXCLUSIVE MODE;
SELECT COALESCE(MAX(seqTxId)+1, 0) INTO NEW.seqTxId FROM base.tx_context;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_commit_order_trigger
BEFORE INSERT ON base.tx_context
FOR EACH ROW
EXECUTE FUNCTION set_next_sequential_txid();
--//
-- ============================================================================ -- ============================================================================
--changeset michael.hoennig:audit-TX-JOURNAL-TABLE endDelimiter:--// --changeset michael.hoennig:audit-TX-JOURNAL-TABLE endDelimiter:--//
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
@@ -53,13 +80,24 @@ create index on base.tx_journal (targetTable, targetUuid);
--// --//
-- ============================================================================ -- ============================================================================
--changeset michael.hoennig:audit-TX-JOURNAL-VIEW endDelimiter:--// --changeset michael.hoennig:audit-TX-JOURNAL-VIEW runOnChange:true validCheckSum:ANY endDelimiter:--//
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
/* /*
A view combining base.tx_journal with base.tx_context. A view combining base.tx_journal with base.tx_context.
*/ */
drop view if exists base.tx_journal_v;
create view base.tx_journal_v as create view base.tx_journal_v as
select txc.*, txj.targettable, txj.targetop, txj.targetuuid, txj.targetdelta select txc.seqTxId,
txc.txId,
txc.txTimeStamp,
txc.currentSubject,
txc.assumedRoles,
txc.currentTask,
txc.currentRequest,
txj.targetTable,
txj.targeTop,
txj.targetUuid,
txj.targetDelta
from base.tx_journal txj from base.tx_journal txj
left join base.tx_context txc using (txId) left join base.tx_context txc using (txId)
order by txc.txtimestamp; order by txc.txtimestamp;

View File

@@ -12,6 +12,9 @@ unknown\ authorization\ ticket=unbekanntes Autorisierungs-Ticket
{0}\ "{1}"\ not\ found\ or\ not\ accessible={0} "{1}" nicht gefunden oder nicht zugänglich {0}\ "{1}"\ not\ found\ or\ not\ accessible={0} "{1}" nicht gefunden oder nicht zugänglich
but\ is=ist aber but\ is=ist aber
# credentials validations
existing\ {0}\ does\ not\ match\ given\ resource\ {1}=existierender Credentials-Context {0} passt nicht zum angegebenen {1}
# office.coop-shares # office.coop-shares
for\ transactionType\={0},\ shareCount\ must\ be\ positive\ but\ is\ {1}=für transactionType={0}, muss shareCount positiv sein, ist aber {1} for\ transactionType\={0},\ shareCount\ must\ be\ positive\ but\ is\ {1}=für transactionType={0}, muss shareCount positiv sein, ist aber {1}
for\ transactionType\={0},\ shareCount\ must\ be\ negative\ but\ is\ {1}=für transactionType={0}, muss shareCount negativ sein, ist aber {1} for\ transactionType\={0},\ shareCount\ must\ be\ negative\ but\ is\ {1}=für transactionType={0}, muss shareCount negativ sein, ist aber {1}

View File

@@ -52,6 +52,7 @@ public class ArchitectureTest {
"..credentials", "..credentials",
"..hash", "..hash",
"..lambda", "..lambda",
"..journal",
"..generated..", "..generated..",
"..persistence..", "..persistence..",
"..reflection", "..reflection",
@@ -155,14 +156,14 @@ public class ArchitectureTest {
public static final ArchRule testPackagesRule = classes() public static final ArchRule testPackagesRule = classes()
.that().resideInAPackage("..test.(*)..") .that().resideInAPackage("..test.(*)..")
.should().onlyBeAccessed().byClassesThat() .should().onlyBeAccessed().byClassesThat()
.resideInAnyPackage("..test.(*).."); .resideInAnyPackage("..test.(*)..", "..journal..");
@ArchTest @ArchTest
@SuppressWarnings("unused") @SuppressWarnings("unused")
public static final ArchRule testPackagePackageRule = classes() public static final ArchRule testPackagePackageRule = classes()
.that().resideInAPackage("..test.pac..") .that().resideInAPackage("..test.pac..")
.should().onlyBeAccessed().byClassesThat() .should().onlyBeAccessed().byClassesThat()
.resideInAnyPackage("..test.pac.."); .resideInAnyPackage("..test.pac..", "..journal..");
@ArchTest @ArchTest
@SuppressWarnings("unused") @SuppressWarnings("unused")
@@ -174,6 +175,7 @@ public class ArchitectureTest {
"..hs.office.(*)..", "..hs.office.(*)..",
"..hs.booking.(*)..", "..hs.booking.(*)..",
"..hs.hosting.(*)..", "..hs.hosting.(*)..",
"..credentials..",
"..hs.scenarios", "..hs.scenarios",
"..hs.migration", "..hs.migration",
"..rbacgrant" // TODO.test: just because of RbacGrantsDiagramServiceIntegrationTest "..rbacgrant" // TODO.test: just because of RbacGrantsDiagramServiceIntegrationTest

View File

@@ -11,10 +11,10 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.PersistenceException; import jakarta.persistence.PersistenceException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.transaction.Transactional;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;

View File

@@ -97,7 +97,7 @@ class HsCredentialsContextsControllerRestTest {
// when // when
mockMvc.perform(MockMvcRequestBuilders mockMvc.perform(MockMvcRequestBuilders
.get("/api/login/contexts") .get("/api/hs/credentials/contexts")
.header("Authorization", "Bearer superuser-alex@hostsharing.net") .header("Authorization", "Bearer superuser-alex@hostsharing.net")
.accept(MediaType.APPLICATION_JSON)) .accept(MediaType.APPLICATION_JSON))
.andDo(print()) .andDo(print())

View File

@@ -1,8 +1,8 @@
package net.hostsharing.hsadminng.credentials; package net.hostsharing.hsadminng.credentials;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource; import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextTypeResource; import net.hostsharing.hsadminng.credentials.generated.api.v1.model.ContextResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource; import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsPatchResource;
import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestInstance;
@@ -20,11 +20,12 @@ import java.util.stream.Stream;
import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
@TestInstance(PER_CLASS) @TestInstance(PER_CLASS)
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase< class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase<
LoginCredentialsPatchResource, CredentialsPatchResource,
HsCredentialsEntity HsCredentialsEntity
> { > {
@@ -58,8 +59,8 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase<
.qualifier("dev") .qualifier("dev")
.build(); .build();
private LoginContextResource patchContextResource2; private ContextResource patchContextResource2;
private LoginContextResource patchContextResource3; private ContextResource patchContextResource3;
// This is what em.find should return for CONTEXT_UUID_3 // This is what em.find should return for CONTEXT_UUID_3
private final HsCredentialsContextRealEntity newContextEntity3 = HsCredentialsContextRealEntity.builder() private final HsCredentialsContextRealEntity newContextEntity3 = HsCredentialsContextRealEntity.builder()
@@ -69,7 +70,7 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase<
.build(); .build();
private final Set<HsCredentialsContextRealEntity> initialContextEntities = Set.of(initialContextEntity1, initialContextEntity2); private final Set<HsCredentialsContextRealEntity> initialContextEntities = Set.of(initialContextEntity1, initialContextEntity2);
private List<LoginContextResource> patchedContextResources; private List<ContextResource> patchedContextResources;
private final Set<HsCredentialsContextRealEntity> expectedPatchedContextEntities = Set.of(initialContextEntity2, newContextEntity3); private final Set<HsCredentialsContextRealEntity> expectedPatchedContextEntities = Set.of(initialContextEntity2, newContextEntity3);
@Mock @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_2))).thenReturn(initialContextEntity2);
lenient().when(em.find(eq(HsCredentialsContextRealEntity.class), eq(CONTEXT_UUID_3))).thenReturn(newContextEntity3); 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.setUuid(CONTEXT_UUID_2);
patchContextResource2.setType(LoginContextTypeResource.SSH); patchContextResource2.setType("SSH");
patchContextResource2.setQualifier("dev"); patchContextResource2.setQualifier("dev");
patchContextResource3 = new LoginContextResource(); patchContextResource3 = new ContextResource();
patchContextResource3.setUuid(CONTEXT_UUID_3); patchContextResource3.setUuid(CONTEXT_UUID_3);
patchContextResource3.setType(LoginContextTypeResource.HSADMIN); patchContextResource3.setType("HSADMIN");
patchContextResource3.setQualifier("test"); patchContextResource3.setQualifier("test");
patchedContextResources = List.of(patchContextResource2, patchContextResource3); patchedContextResources = List.of(patchContextResource2, patchContextResource3);
@@ -110,13 +111,13 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase<
} }
@Override @Override
protected LoginCredentialsPatchResource newPatchResource() { protected CredentialsPatchResource newPatchResource() {
return new LoginCredentialsPatchResource(); return new CredentialsPatchResource();
} }
@Override @Override
protected HsCredentialsEntityPatcher createPatcher(final HsCredentialsEntity entity) { protected HsCredentialsEntityPatcher createPatcher(final HsCredentialsEntity entity) {
return new HsCredentialsEntityPatcher(em, entity); return new HsCredentialsEntityPatcher(em, mock(MessageTranslator.class), entity);
} }
@Override @Override
@@ -124,38 +125,38 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase<
return Stream.of( return Stream.of(
new SimpleProperty<>( new SimpleProperty<>(
"active", "active",
LoginCredentialsPatchResource::setActive, CredentialsPatchResource::setActive,
PATCHED_ACTIVE, PATCHED_ACTIVE,
HsCredentialsEntity::setActive, HsCredentialsEntity::setActive,
PATCHED_ACTIVE) PATCHED_ACTIVE)
.notNullable(), .notNullable(),
new JsonNullableProperty<>( new JsonNullableProperty<>(
"emailAddress", "emailAddress",
LoginCredentialsPatchResource::setEmailAddress, CredentialsPatchResource::setEmailAddress,
PATCHED_EMAIL_ADDRESS, PATCHED_EMAIL_ADDRESS,
HsCredentialsEntity::setEmailAddress, HsCredentialsEntity::setEmailAddress,
PATCHED_EMAIL_ADDRESS), PATCHED_EMAIL_ADDRESS),
new JsonNullableProperty<>( new JsonNullableProperty<>(
"twoFactorAuth", "twoFactorAuth",
LoginCredentialsPatchResource::setTwoFactorAuth, CredentialsPatchResource::setTwoFactorAuth,
PATCHED_TWO_FACTOR_AUTH, PATCHED_TWO_FACTOR_AUTH,
HsCredentialsEntity::setTwoFactorAuth, HsCredentialsEntity::setTwoFactorAuth,
PATCHED_TWO_FACTOR_AUTH), PATCHED_TWO_FACTOR_AUTH),
new JsonNullableProperty<>( new JsonNullableProperty<>(
"smsNumber", "smsNumber",
LoginCredentialsPatchResource::setSmsNumber, CredentialsPatchResource::setSmsNumber,
PATCHED_SMS_NUMBER, PATCHED_SMS_NUMBER,
HsCredentialsEntity::setSmsNumber, HsCredentialsEntity::setSmsNumber,
PATCHED_SMS_NUMBER), PATCHED_SMS_NUMBER),
new JsonNullableProperty<>( new JsonNullableProperty<>(
"phonePassword", "phonePassword",
LoginCredentialsPatchResource::setPhonePassword, CredentialsPatchResource::setPhonePassword,
PATCHED_PHONE_PASSWORD, PATCHED_PHONE_PASSWORD,
HsCredentialsEntity::setPhonePassword, HsCredentialsEntity::setPhonePassword,
PATCHED_PHONE_PASSWORD), PATCHED_PHONE_PASSWORD),
new SimpleProperty<>( new SimpleProperty<>(
"contexts", "contexts",
LoginCredentialsPatchResource::setContexts, CredentialsPatchResource::setContexts,
patchedContextResources, patchedContextResources,
HsCredentialsEntity::setLoginContexts, HsCredentialsEntity::setLoginContexts,
expectedPatchedContextEntities) expectedPatchedContextEntities)

View File

@@ -42,7 +42,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest {
HttpServletRequest request; HttpServletRequest request;
@Autowired @Autowired
private HsCredentialsRepository loginCredentialsRepository; private HsCredentialsRepository credentialsRepository;
@Autowired @Autowired
private HsCredentialsContextRealRepository loginContextRealRepo; private HsCredentialsContextRealRepository loginContextRealRepo;
@@ -88,7 +88,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest {
@Test @Test
void shouldFindByUuidUsingTestData() { void shouldFindByUuidUsingTestData() {
// when // when
final var foundEntityOptional = loginCredentialsRepository.findByUuid(alexSubject.getUuid()); final var foundEntityOptional = credentialsRepository.findByUuid(alexSubject.getUuid());
// then // then
assertThat(foundEntityOptional).isPresent() assertThat(foundEntityOptional).isPresent()
@@ -96,7 +96,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest {
} }
@Test @Test
void shouldSaveLoginCredentialsWithExistingContext() { void shouldSaveCredentialsWithExistingContext() {
// given // given
final var existingContext = loginContextRealRepo.findByTypeAndQualifier("HSADMIN", "prod") final var existingContext = loginContextRealRepo.findByTypeAndQualifier("HSADMIN", "prod")
.orElseThrow(); .orElseThrow();
@@ -111,12 +111,12 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest {
.build(); .build();
// when // when
loginCredentialsRepository.save(newCredentials); credentialsRepository.save(newCredentials);
em.flush(); em.flush();
em.clear(); em.clear();
// then // then
final var foundEntityOptional = loginCredentialsRepository.findByUuid(drewSubject.getUuid()); final var foundEntityOptional = credentialsRepository.findByUuid(drewSubject.getUuid());
assertThat(foundEntityOptional).isPresent(); assertThat(foundEntityOptional).isPresent();
final var foundEntity = foundEntityOptional.get(); final var foundEntity = foundEntityOptional.get();
assertThat(foundEntity.getEmailAddress()).isEqualTo("drew.new@example.com"); assertThat(foundEntity.getEmailAddress()).isEqualTo("drew.new@example.com");
@@ -129,7 +129,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest {
} }
@Test @Test
void shouldNotSaveLoginCredentialsWithNewContext() { void shouldNotSaveCredentialsWithNewContext() {
// given // given
final var newContext = HsCredentialsContextRealEntity.builder() final var newContext = HsCredentialsContextRealEntity.builder()
.type("MATRIX") .type("MATRIX")
@@ -146,7 +146,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest {
// when // when
final var exception = catchThrowable(() -> { final var exception = catchThrowable(() -> {
loginCredentialsRepository.save(newCredentials); credentialsRepository.save(newCredentials);
em.flush(); em.flush();
}); });
@@ -155,7 +155,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest {
} }
@Test @Test
void shouldSaveNewLoginCredentialsWithoutContext() { void shouldSaveNewCredentialsWithoutContext() {
// given // given
final var newCredentials = HsCredentialsEntity.builder() final var newCredentials = HsCredentialsEntity.builder()
.subject(testUserSubject) .subject(testUserSubject)
@@ -167,12 +167,12 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest {
.build(); .build();
// when // when
loginCredentialsRepository.save(newCredentials); credentialsRepository.save(newCredentials);
em.flush(); em.flush();
em.clear(); em.clear();
// then // then
final var foundEntityOptional = loginCredentialsRepository.findByUuid(testUserSubject.getUuid()); final var foundEntityOptional = credentialsRepository.findByUuid(testUserSubject.getUuid());
assertThat(foundEntityOptional).isPresent(); assertThat(foundEntityOptional).isPresent();
final var foundEntity = foundEntityOptional.get(); final var foundEntity = foundEntityOptional.get();
assertThat(foundEntity.getEmailAddress()).isEqualTo("test.user.new@example.com"); assertThat(foundEntity.getEmailAddress()).isEqualTo("test.user.new@example.com");
@@ -183,21 +183,21 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest {
} }
@Test @Test
void shouldUpdateExistingLoginCredentials() { void shouldUpdateExistingCredentials() {
// given // given
final var entityToUpdate = loginCredentialsRepository.findByUuid(alexSubject.getUuid()).orElseThrow(); final var entityToUpdate = credentialsRepository.findByUuid(alexSubject.getUuid()).orElseThrow();
final var initialVersion = entityToUpdate.getVersion(); final var initialVersion = entityToUpdate.getVersion();
// when // when
entityToUpdate.setActive(false); entityToUpdate.setActive(false);
entityToUpdate.setEmailAddress("updated.user1@example.com"); entityToUpdate.setEmailAddress("updated.user1@example.com");
final var savedEntity = loginCredentialsRepository.save(entityToUpdate); final var savedEntity = credentialsRepository.save(entityToUpdate);
em.flush(); em.flush();
em.clear(); em.clear();
// then // then
assertThat(savedEntity.getVersion()).isGreaterThan(initialVersion); assertThat(savedEntity.getVersion()).isGreaterThan(initialVersion);
final var updatedEntityOptional = loginCredentialsRepository.findByUuid(alexSubject.getUuid()); final var updatedEntityOptional = credentialsRepository.findByUuid(alexSubject.getUuid());
assertThat(updatedEntityOptional).isPresent(); assertThat(updatedEntityOptional).isPresent();
final var updatedEntity = updatedEntityOptional.get(); final var updatedEntity = updatedEntityOptional.get();
assertThat(updatedEntity.isActive()).isFalse(); assertThat(updatedEntity.isActive()).isFalse();

View File

@@ -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());
}
}