1
0

bugfix: fixes HTTP POST on credentials, including person+subject (#184)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/184
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig
2025-07-07 21:09:37 +02:00
parent fee080dbf4
commit 3603ea911e
23 changed files with 482 additions and 126 deletions

View File

@@ -0,0 +1,67 @@
package net.hostsharing.hsadminng.credentials;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityNotFoundException;
import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.ContextResource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class CredentialContextResourceToEntityMapper {
private final EntityManager em;
private final MessageTranslator messageTranslator;
@Autowired
public CredentialContextResourceToEntityMapper(EntityManager em, MessageTranslator messageTranslator) {
this.em = em;
this.messageTranslator = messageTranslator;
}
public Set<HsCredentialsContextRealEntity> mapCredentialsToContextEntities(
List<ContextResource> resources
) {
final var entities = new HashSet<HsCredentialsContextRealEntity>();
syncCredentialsContextEntities(resources, entities);
return entities;
}
public void syncCredentialsContextEntities(
List<ContextResource> resources,
Set<HsCredentialsContextRealEntity> entities
) {
final var resourceUuids = resources.stream()
.map(ContextResource::getUuid)
.collect(Collectors.toSet());
final var entityUuids = entities.stream()
.map(HsCredentialsContextRealEntity::getUuid)
.collect(Collectors.toSet());
entities.removeIf(e -> !resourceUuids.contains(e.getUuid()));
for (final var resource : resources) {
if (!entityUuids.contains(resource.getUuid())) {
final var existingContextEntity = em.find(HsCredentialsContextRealEntity.class, resource.getUuid());
if (existingContextEntity == null) {
throw new EntityNotFoundException(
messageTranslator.translate("{0} \"{1}\" not found or not accessible",
"credentials uuid", resource.getUuid()));
}
if (!existingContextEntity.getType().equals(resource.getType()) &&
!existingContextEntity.getQualifier().equals(resource.getQualifier())) {
throw new EntityNotFoundException(
messageTranslator.translate("existing {0} does not match given resource {1}",
existingContextEntity, resource));
}
entities.add(existingContextEntity);
}
}
}
}

View File

@@ -10,6 +10,7 @@ import net.hostsharing.hsadminng.credentials.generated.api.v1.model.ContextResou
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.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@@ -26,6 +27,7 @@ public class HsCredentialsContextsController implements ContextsApi {
private HsCredentialsContextRbacRepository contextRepo; private HsCredentialsContextRbacRepository contextRepo;
@Override @Override
@Transactional(readOnly = true)
@Timed("app.credentials.contexts.getListOfLoginContexts") @Timed("app.credentials.contexts.getListOfLoginContexts")
public ResponseEntity<List<ContextResource>> getListOfContexts(final String assumedRoles) { public ResponseEntity<List<ContextResource>> getListOfContexts(final String assumedRoles) {
context.assumeRoles(assumedRoles); context.assumeRoles(assumedRoles);

View File

@@ -2,7 +2,6 @@ package net.hostsharing.hsadminng.credentials;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
@@ -15,21 +14,27 @@ 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.CredentialsInsertResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsPatchResource; import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsPatchResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsResource; import net.hostsharing.hsadminng.credentials.generated.api.v1.model.CredentialsResource;
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.HsOfficePersonResource;
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;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
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.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import jakarta.persistence.EntityNotFoundException; import jakarta.persistence.EntityNotFoundException;
import static java.util.Optional.ofNullable;
import static java.util.Optional.of;
@RestController @RestController
@SecurityRequirement(name = "casTicket") @SecurityRequirement(name = "casTicket")
public class HsCredentialsController implements CredentialsApi { public class HsCredentialsController implements CredentialsApi {
private static DateTimeFormatter FULL_TIMESTAMP_FORMAT = DateTimeFormatter.BASIC_ISO_DATE;
@Autowired @Autowired
private Context context; private Context context;
@@ -39,16 +44,23 @@ public class HsCredentialsController implements CredentialsApi {
@Autowired @Autowired
private StrictMapper mapper; private StrictMapper mapper;
@Autowired
private RbacSubjectRepository subjectRepo;
@Autowired
private CredentialContextResourceToEntityMapper contextMapper;
@Autowired @Autowired
private MessageTranslator messageTranslator; private MessageTranslator messageTranslator;
@Autowired @Autowired
private HsOfficePersonRbacRepository personRepo; private HsOfficePersonRbacRepository rbacPersonRepo;
@Autowired @Autowired
private HsCredentialsRepository credentialsRepo; private HsCredentialsRepository credentialsRepo;
@Override @Override
@Transactional(readOnly = true)
@Timed("app.credentials.credentials.getSingleCredentialsByUuid") @Timed("app.credentials.credentials.getSingleCredentialsByUuid")
public ResponseEntity<CredentialsResource> getSingleCredentialsByUuid( public ResponseEntity<CredentialsResource> getSingleCredentialsByUuid(
final String assumedRoles, final String assumedRoles,
@@ -56,11 +68,16 @@ public class HsCredentialsController implements CredentialsApi {
context.assumeRoles(assumedRoles); context.assumeRoles(assumedRoles);
final var credentials = credentialsRepo.findByUuid(credentialsUuid); final var credentials = credentialsRepo.findByUuid(credentialsUuid);
final var result = mapper.map(credentials, CredentialsResource.class); if (credentials.isEmpty()) {
return ResponseEntity.notFound().build();
}
final var result = mapper.map(
credentials.get(), CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@Override @Override
@Transactional(readOnly = true)
@Timed("app.credentials.credentials.getListOfCredentialsByPersonUuid") @Timed("app.credentials.credentials.getListOfCredentialsByPersonUuid")
public ResponseEntity<List<CredentialsResource>> getListOfCredentialsByPersonUuid( public ResponseEntity<List<CredentialsResource>> getListOfCredentialsByPersonUuid(
final String assumedRoles, final String assumedRoles,
@@ -68,18 +85,20 @@ public class HsCredentialsController implements CredentialsApi {
) { ) {
context.assumeRoles(assumedRoles); context.assumeRoles(assumedRoles);
final var person = personRepo.findByUuid(personUuid).orElseThrow( final var person = rbacPersonRepo.findByUuid(personUuid).orElseThrow(
() -> new EntityNotFoundException( () -> new EntityNotFoundException(
messageTranslator.translate("{0} \"{1}\" not found or not accessible", "personUuid", personUuid) messageTranslator.translate("{0} \"{1}\" not found or not accessible", "personUuid", personUuid)
) )
); );
final var credentials = credentialsRepo.findByPerson(person); final var credentials = credentialsRepo.findByPerson(person);
final var result = mapper.mapList(credentials, CredentialsResource.class); final var result = mapper.mapList(
credentials, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@Override @Override
@Transactional
@Timed("app.credentials.credentials.postNewCredentials") @Timed("app.credentials.credentials.postNewCredentials")
public ResponseEntity<CredentialsResource> postNewCredentials( public ResponseEntity<CredentialsResource> postNewCredentials(
final String assumedRoles, final String assumedRoles,
@@ -87,13 +106,28 @@ public class HsCredentialsController implements CredentialsApi {
) { ) {
context.assumeRoles(assumedRoles); context.assumeRoles(assumedRoles);
final var newCredentialsEntity = mapper.map(body, HsCredentialsEntity.class); // first create and save the subject to get its UUID
final var savedCredentialsEntity = credentialsRepo.save(newCredentialsEntity); final var newlySavedSubject = createSubject(body.getNickname());
final var newCredentialsResource = mapper.map(savedCredentialsEntity, CredentialsResource.class);
return ResponseEntity.ok(newCredentialsResource); // afterward, create and save the credentials entity with the subject's UUID
final var newCredentialsEntity = mapper.map(
body, HsCredentialsEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
newCredentialsEntity.setSubject(newlySavedSubject);
em.persist(newCredentialsEntity); // newCredentialsEntity.uuid == newlySavedSubject.uuid => do not use repository!
// return the new credentials as a resource
final var uri =
MvcUriComponentsBuilder.fromController(getClass())
.path("/api/hs/credentials/credentials/{id}")
.buildAndExpand(newCredentialsEntity.getUuid())
.toUri();
final var newCredentialsResource = mapper.map(
newCredentialsEntity, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.created(uri).body(newCredentialsResource);
} }
@Override @Override
@Transactional
@Timed("app.credentials.credentials.deleteCredentialsByUuid") @Timed("app.credentials.credentials.deleteCredentialsByUuid")
public ResponseEntity<Void> deleteCredentialsByUuid(final String assumedRoles, final UUID credentialsUuid) { public ResponseEntity<Void> deleteCredentialsByUuid(final String assumedRoles, final UUID credentialsUuid) {
context.assumeRoles(assumedRoles); context.assumeRoles(assumedRoles);
@@ -103,6 +137,7 @@ public class HsCredentialsController implements CredentialsApi {
} }
@Override @Override
@Transactional
@Timed("app.credentials.credentials.patchCredentials") @Timed("app.credentials.credentials.patchCredentials")
public ResponseEntity<CredentialsResource> patchCredentials( public ResponseEntity<CredentialsResource> patchCredentials(
final String assumedRoles, final String assumedRoles,
@@ -113,10 +148,11 @@ public class HsCredentialsController implements CredentialsApi {
final var current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow(); final var current = credentialsRepo.findByUuid(credentialsUuid).orElseThrow();
new HsCredentialsEntityPatcher(em, messageTranslator, current).apply(body); new HsCredentialsEntityPatcher(contextMapper, current).apply(body);
final var saved = credentialsRepo.save(current); final var saved = credentialsRepo.save(current);
final var mapped = mapper.map(saved, CredentialsResource.class); final var mapped = mapper.map(
saved, CredentialsResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(mapped); return ResponseEntity.ok(mapped);
} }
@@ -137,7 +173,38 @@ public class HsCredentialsController implements CredentialsApi {
return ResponseEntity.ok(mapped); return ResponseEntity.ok(mapped);
} }
private RbacSubjectEntity createSubject(final String nickname) {
final var newRbacSubject = subjectRepo.create(new RbacSubjectEntity(null, nickname));
if(context.fetchCurrentSubject() == null) {
context.define("activate newly created self-servie subject", null, nickname, null);
}
return subjectRepo.findByUuid(newRbacSubject.getUuid()); // attached to EM
}
final BiConsumer<HsCredentialsEntity, CredentialsResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> { final BiConsumer<HsCredentialsEntity, CredentialsResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
resource.setLastUsed(entity.getLastUsed().atOffset(ZoneOffset.UTC)); ofNullable(entity.getLastUsed()).ifPresent(
dt -> resource.setLastUsed(dt.atOffset(ZoneOffset.UTC)));
of(entity.getSubject()).ifPresent(
subject -> resource.setNickname(subject.getName())
);
of(entity.getPerson()).ifPresent(
person -> resource.setPerson(
mapper.map(person, HsOfficePersonResource.class)
)
);
};
final BiConsumer<CredentialsInsertResource, HsCredentialsEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
// TODO.impl: we need to make sure that the current subject is OWNER (or ADMIN?) of the person
final var person = rbacPersonRepo.findByUuid(resource.getPersonUuid()).orElseThrow(
() -> new EntityNotFoundException(
messageTranslator.translate("{0} \"{1}\" not found or not accessible", "personUuid", resource.getPersonUuid())
)
);
entity.setLoginContexts(contextMapper.mapCredentialsToContextEntities(resource.getContexts()));
entity.setPerson(person);
}; };
} }

View File

@@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.credentials;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity;
import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.repr.Stringify; import net.hostsharing.hsadminng.repr.Stringify;
@@ -45,7 +45,7 @@ public class HsCredentialsEntity implements BaseEntity<HsCredentialsEntity>, Str
@ManyToOne(optional = false, fetch = FetchType.EAGER) @ManyToOne(optional = false, fetch = FetchType.EAGER)
@JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid") @JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
private HsOfficePersonRealEntity person; private HsOfficePersonRbacEntity person;
@Version @Version
private int version; private int version;
@@ -92,6 +92,11 @@ public class HsCredentialsEntity implements BaseEntity<HsCredentialsEntity>, Str
return loginContexts; return loginContexts;
} }
public void setSubject(final RbacSubjectEntity subject) {
this.uuid = subject.getUuid();
this.subject = subject;
}
@Override @Override
public String toShortString() { public String toShortString() {
return active + ":" + emailAddress + ":" + globalUid; return active + ":" + emailAddress + ":" + globalUid;

View File

@@ -1,26 +1,17 @@
package net.hostsharing.hsadminng.credentials; package net.hostsharing.hsadminng.credentials;
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.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;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class HsCredentialsEntityPatcher implements EntityPatcher<CredentialsPatchResource> { public class HsCredentialsEntityPatcher implements EntityPatcher<CredentialsPatchResource> {
private final EntityManager em; private CredentialContextResourceToEntityMapper contextMapper;
private MessageTranslator messageTranslator;
private final HsCredentialsEntity entity; private final HsCredentialsEntity entity;
public HsCredentialsEntityPatcher(final EntityManager em, MessageTranslator messageTranslator, final HsCredentialsEntity entity) { public HsCredentialsEntityPatcher(final CredentialContextResourceToEntityMapper contextMapper, final HsCredentialsEntity entity) {
this.em = em; this.contextMapper = contextMapper;
this.messageTranslator = messageTranslator;
this.entity = entity; this.entity = entity;
} }
@@ -38,40 +29,7 @@ public class HsCredentialsEntityPatcher implements EntityPatcher<CredentialsPatc
OptionalFromJson.of(resource.getPhonePassword()) OptionalFromJson.of(resource.getPhonePassword())
.ifPresent(entity::setPhonePassword); .ifPresent(entity::setPhonePassword);
if (resource.getContexts() != null) { if (resource.getContexts() != null) {
syncLoginContextEntities(resource.getContexts(), entity.getLoginContexts()); contextMapper.syncCredentialsContextEntities(resource.getContexts(), entity.getLoginContexts());
}
}
public void syncLoginContextEntities(
List<ContextResource> resources,
Set<HsCredentialsContextRealEntity> entities
) {
final var resourceUuids = resources.stream()
.map(ContextResource::getUuid)
.collect(Collectors.toSet());
final var entityUuids = entities.stream()
.map(HsCredentialsContextRealEntity::getUuid)
.collect(Collectors.toSet());
entities.removeIf(e -> !resourceUuids.contains(e.getUuid()));
for (final var resource : resources) {
if (!entityUuids.contains(resource.getUuid())) {
final var existingContextEntity = em.find(HsCredentialsContextRealEntity.class, resource.getUuid());
if ( existingContextEntity == null ) {
throw new EntityNotFoundException(
messageTranslator.translate("{0} \"{1}\" not found or not accessible",
"credentials uuid", resource.getUuid()));
}
if (!existingContextEntity.getType().equals(resource.getType()) &&
!existingContextEntity.getQualifier().equals(resource.getQualifier())) {
throw new EntityNotFoundException(
messageTranslator.translate("existing {0} does not match given resource {1}",
existingContextEntity, resource));
}
entities.add(existingContextEntity);
}
} }
} }

View File

@@ -16,6 +16,7 @@ import java.util.UUID;
@Table(schema = "rbac", name = "subject_rv") @Table(schema = "rbac", name = "subject_rv")
@Getter @Getter
@Setter @Setter
@Builder
@ToString @ToString
@Immutable @Immutable
@NoArgsConstructor @NoArgsConstructor

View File

@@ -45,7 +45,7 @@ public interface RbacSubjectRepository extends Repository<RbacSubjectEntity, UUI
rbacSubjectEntity.setUuid(UUID.randomUUID()); rbacSubjectEntity.setUuid(UUID.randomUUID());
} }
insert(rbacSubjectEntity); insert(rbacSubjectEntity);
return rbacSubjectEntity; return rbacSubjectEntity; // Not yet attached to EM!
} }
@Timed("app.rbac.subjects.repo.deleteByUuid") @Timed("app.rbac.subjects.repo.deleteByUuid")

View File

@@ -2,7 +2,7 @@ get:
summary: Returns a list of all accessible contexts. summary: Returns a list of all accessible contexts.
description: Returns the list of all contexts which are visible to the current subject or any of it's assumed roles. description: Returns the list of all contexts which are visible to the current subject or any of it's assumed roles.
tags: tags:
- -contexts - contexts
operationId: getListOfContexts operationId: getListOfContexts
parameters: parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles' - $ref: 'auth.yaml#/components/parameters/assumedRoles'

View File

@@ -9,6 +9,11 @@ components:
uuid: uuid:
type: string type: string
format: uuid format: uuid
person:
$ref: '../hs-office/hs-office-person-schemas.yaml#/components/schemas/HsOfficePerson'
nickname:
type: string
pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname
totpSecret: totpSecret:
type: string type: string
telephonePassword: telephonePassword:
@@ -64,9 +69,12 @@ components:
CredentialsInsert: CredentialsInsert:
type: object type: object
properties: properties:
uuid: person.uuid:
type: string type: string
format: uuid format: uuid
nickname:
type: string
pattern: '^[a-z][a-z0-9]{1,8}-[a-z0-9]{1,10}$' # TODO.spec: pattern for login nickname
totpSecret: totpSecret:
type: string type: string
telephonePassword: telephonePassword:

View File

@@ -1,6 +1,6 @@
post: post:
tags: tags:
- -credentials - credentials
description: 'Is called when credentials got used for a login.' description: 'Is called when credentials got used for a login.'
operationId: credentialsUsed operationId: credentialsUsed
parameters: parameters:

View File

@@ -1,11 +1,11 @@
get: get:
tags: tags:
- -credentials - credentials
description: 'Fetch a single credentials its uuid, if visible for the current subject.' description: 'Fetch a single credentials its uuid, if visible for the current subject.'
operationId: getSingleCredentialsByUuid operationId: getSingleCredentialsByUuid
parameters: parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles' - $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: CredentialsUuid - name: credentialsUuid
in: path in: path
required: true required: true
schema: schema:
@@ -27,12 +27,12 @@ get:
patch: patch:
tags: tags:
- -credentials - credentials
description: 'Updates a single credentials identified by its uuid, if permitted for the current subject.' description: 'Updates a single credentials identified by its uuid, if permitted for the current subject.'
operationId: patchCredentials operationId: patchCredentials
parameters: parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles' - $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: CredentialsUuid - name: credentialsUuid
in: path in: path
required: true required: true
schema: schema:
@@ -57,7 +57,7 @@ patch:
delete: delete:
tags: tags:
- -credentials - credentials
description: 'Delete a single credentials identified by its uuid, if permitted for the current subject.' description: 'Delete a single credentials identified by its uuid, if permitted for the current subject.'
operationId: deleteCredentialsByUuid operationId: deleteCredentialsByUuid
parameters: parameters:

View File

@@ -2,7 +2,7 @@ get:
summary: Returns a list of all credentials. summary: Returns a list of all credentials.
description: Returns the list of all credentials which are visible to the current subject or any of it's assumed roles. description: Returns the list of all credentials which are visible to the current subject or any of it's assumed roles.
tags: tags:
- -credentials - credentials
operationId: getListOfCredentialsByPersonUuid operationId: getListOfCredentialsByPersonUuid
parameters: parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles' - $ref: 'auth.yaml#/components/parameters/assumedRoles'
@@ -30,7 +30,7 @@ get:
post: post:
summary: Adds a new credentials. summary: Adds a new credentials.
tags: tags:
- -credentials - credentials
operationId: postNewCredentials operationId: postNewCredentials
parameters: parameters:
- $ref: 'auth.yaml#/components/parameters/assumedRoles' - $ref: 'auth.yaml#/components/parameters/assumedRoles'

View File

@@ -50,6 +50,7 @@ public class ArchitectureTest {
"..test.dom", "..test.dom",
"..context", "..context",
"..credentials", "..credentials",
"..credentials.scenarios",
"..hash", "..hash",
"..lambda", "..lambda",
"..journal", "..journal",

View File

@@ -4,9 +4,13 @@ import net.hostsharing.hsadminng.config.DisableSecurityConfig;
import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration; import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration;
import net.hostsharing.hsadminng.config.MessageTranslator; import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
import net.hostsharing.hsadminng.mapper.StrictMapper; import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper; import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
import org.hamcrest.CustomMatcher; import org.hamcrest.CustomMatcher;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -37,6 +41,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@ActiveProfiles("test") @ActiveProfiles("test")
class HsCredentialsControllerRestTest { class HsCredentialsControllerRestTest {
private static final UUID PERSON_UUID = UUID.randomUUID();
@Autowired @Autowired
MockMvc mockMvc; MockMvc mockMvc;
@@ -54,7 +60,13 @@ class HsCredentialsControllerRestTest {
EntityManagerFactory emf; EntityManagerFactory emf;
@MockitoBean @MockitoBean
HsOfficePersonRbacRepository personRbacRepo; RbacSubjectRepository subjectRepo;
@MockitoBean
HsOfficePersonRealRepository realPersonRepo;
@MockitoBean
HsOfficePersonRbacRepository rbacPersonRepo;
@MockitoBean @MockitoBean
HsCredentialsContextRbacRepository loginContextRbacRepo; HsCredentialsContextRbacRepository loginContextRbacRepo;
@@ -62,6 +74,9 @@ class HsCredentialsControllerRestTest {
@MockitoBean @MockitoBean
HsCredentialsRepository credentialsRepo; HsCredentialsRepository credentialsRepo;
@MockitoBean
CredentialContextResourceToEntityMapper contextMapper;
@Test @Test
void patchCredentialsUsed() throws Exception { void patchCredentialsUsed() throws Exception {
@@ -70,6 +85,8 @@ class HsCredentialsControllerRestTest {
when(credentialsRepo.findByUuid(givenCredentialsUuid)).thenReturn(Optional.of( when(credentialsRepo.findByUuid(givenCredentialsUuid)).thenReturn(Optional.of(
HsCredentialsEntity.builder() HsCredentialsEntity.builder()
.uuid(givenCredentialsUuid) .uuid(givenCredentialsUuid)
.person(HsOfficePersonRbacEntity.builder().uuid(PERSON_UUID).build())
.subject(RbacSubjectEntity.builder().name("some-nickname").build())
.lastUsed(null) .lastUsed(null)
.onboardingToken("fake-onboarding-token") .onboardingToken("fake-onboarding-token")
.build() .build()

View File

@@ -117,7 +117,8 @@ class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase<
@Override @Override
protected HsCredentialsEntityPatcher createPatcher(final HsCredentialsEntity entity) { protected HsCredentialsEntityPatcher createPatcher(final HsCredentialsEntity entity) {
return new HsCredentialsEntityPatcher(em, mock(MessageTranslator.class), entity); final var contextMapper = new CredentialContextResourceToEntityMapper(em, mock(MessageTranslator.class));
return new HsCredentialsEntityPatcher(contextMapper, entity);
} }
@Override @Override

View File

@@ -1,7 +1,7 @@
package net.hostsharing.hsadminng.credentials; package net.hostsharing.hsadminng.credentials;
import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity;
import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; import net.hostsharing.hsadminng.rbac.context.ContextBasedTest;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity; import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
@@ -51,8 +51,8 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest {
private RbacSubjectEntity alexSubject; private RbacSubjectEntity alexSubject;
private RbacSubjectEntity drewSubject; private RbacSubjectEntity drewSubject;
private RbacSubjectEntity testUserSubject; private RbacSubjectEntity testUserSubject;
private HsOfficePersonRealEntity drewPerson; private HsOfficePersonRbacEntity drewPerson;
private HsOfficePersonRealEntity testUserPerson; private HsOfficePersonRbacEntity testUserPerson;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
@@ -218,13 +218,13 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest {
} }
} }
private HsOfficePersonRealEntity fetchPersonByGivenName(final String givenName) { private HsOfficePersonRbacEntity fetchPersonByGivenName(final String givenName) {
final String jpql = "SELECT p FROM HsOfficePersonRealEntity p WHERE p.givenName = :givenName"; final String jpql = "SELECT p FROM HsOfficePersonRbacEntity p WHERE p.givenName = :givenName";
final Query query = em.createQuery(jpql, HsOfficePersonRealEntity.class); final Query query = em.createQuery(jpql, HsOfficePersonRbacEntity.class);
query.setParameter("givenName", givenName); query.setParameter("givenName", givenName);
try { try {
context(SUPERUSER_ALEX_SUBJECT_NAME); context(SUPERUSER_ALEX_SUBJECT_NAME);
return notNull((HsOfficePersonRealEntity) query.getSingleResult()); return notNull((HsOfficePersonRbacEntity) query.getSingleResult());
} catch (final NoResultException e) { } catch (final NoResultException e) {
throw new AssertionError( throw new AssertionError(
"Failed to find person with name '" + givenName + "'. Ensure test data is present.", e); "Failed to find person with name '" + givenName + "'. Ensure test data is present.", e);

View File

@@ -0,0 +1,63 @@
package net.hostsharing.hsadminng.credentials.scenarios;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.hs.scenarios.UseCase;
import org.springframework.http.HttpStatus;
import static io.restassured.http.ContentType.JSON;
import static org.springframework.http.HttpStatus.OK;
public class CreateCredentials extends UseCase<CreateCredentials> {
public CreateCredentials(final ScenarioTest testSuite) {
super(testSuite);
introduction("A set of credentials contains the login data for an RBAC subject.");
}
@Override
protected HttpResponse run() {
obtain("Person: %{personGivenName} %{personFamilyName}", () ->
httpGet("/api/hs/office/persons?name=%{personFamilyName}")
.expecting(OK).expecting(JSON),
response -> response.expectArrayElements(1).getFromBody("[0].uuid"),
"In real situations we have more precise measures to find the related person."
);
obtain("CredentialsContexts", () ->
httpGet("/api/hs/credentials/contexts").expecting(OK).expecting(JSON)
);
return obtain("newCredentials", () ->
httpPost("/api/hs/credentials/credentials", usingJsonBody("""
{
"person.uuid": ${Person: %{personGivenName} %{personFamilyName}},
"nickname": ${nickname},
"active": %{active},
"emailAddress": ${emailAddress},
"telephonePassword": ${telephonePassword},
"smsNumber": ${smsNumber},
"globalUid": %{globalUid},
"globalGid": %{globalGid},
"contexts": @{contexts}
}
"""))
.expecting(HttpStatus.CREATED).expecting(ContentType.JSON)
);
}
@Override
protected void verify(final UseCase<CreateCredentials>.HttpResponse response) {
verify(
"Verify the New Credentials",
() -> httpGet("/api/hs/credentials/credentials/%{newCredentials}")
.expecting(OK).expecting(JSON),
path("uuid").contains("%{newCredentials}"),
path("nickname").contains("%{nickname}"),
path("person.uuid").contains("%{Person: %{personGivenName} %{personFamilyName}}")
);
}
}

View File

@@ -0,0 +1,83 @@
package net.hostsharing.hsadminng.credentials.scenarios;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.HsadminNgApplication;
import net.hostsharing.hsadminng.config.DisableSecurityConfig;
import net.hostsharing.hsadminng.hs.scenarios.Produces;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.mapper.Array;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import net.hostsharing.hsadminng.test.IgnoreOnFailureExtension;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.ClassOrderer;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestClassOrder;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.TestMethodOrder;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.util.Map;
@Tag("scenarioTest")
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
classes = { HsadminNgApplication.class, DisableSecurityConfig.class, JpaAttempt.class },
properties = {
"spring.datasource.url=${HSADMINNG_POSTGRES_JDBC_URL:jdbc:tc:postgresql:15.5-bookworm:///scenariosTC}",
"spring.datasource.username=${HSADMINNG_POSTGRES_ADMIN_USERNAME:ADMIN}",
"spring.datasource.password=${HSADMINNG_POSTGRES_ADMIN_PASSWORD:password}",
"hsadminng.superuser=${HSADMINNG_SUPERUSER:superuser-alex@hostsharing.net}"
}
)
@ActiveProfiles("test")
@TestClassOrder(ClassOrderer.OrderAnnotation.class)
@ExtendWith(IgnoreOnFailureExtension.class)
class CredentialsScenarioTests extends ScenarioTest {
@SneakyThrows
@BeforeEach
protected void beforeScenario(final TestInfo testInfo) {
super.beforeScenario(testInfo);
}
@Nested
@Order(10)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CredentialScenarios {
@Test
@Order(1010)
@Produces(explicitly = "Credentials@hsadmin: firby-susan", implicitly = { "Person: Susan Firby" })
void shouldCreateInitialCredentialsForExistingNaturalPerson() {
new CreateCredentials(scenarioTest)
// to find a specific existing person
.given("personFamilyName", "Firby")
.given("personGivenName", "Susan")
// a login name, to be stored in the new RBAC subject
.given("nickname", "firby-susan")
// initial credentials
.given("active", true)
.given("emailAddress", "susan.firby@example.com")
.given("telephonePassword", "securePass123")
.given("smsNumber", "+49123456789")
.given("globalUid", 21011)
.given("globalGid", 21011)
.given("contexts", Array.of(
Map.ofEntries(
// a hardcoded context from test-data
// TODO.impl: the uuid should be determined within CreateCredentials just by (HSDAMIN,prod)
Map.entry("uuid", "11111111-1111-1111-1111-111111111111"),
Map.entry("type", "HSADMIN"),
Map.entry("qualifier", "prod")
)
))
.doRun()
.keep();
}
}
}

View File

@@ -1,6 +1,9 @@
package net.hostsharing.hsadminng.hs.office.scenarios; package net.hostsharing.hsadminng.hs.office.scenarios;
import lombok.SneakyThrows;
import net.hostsharing.hsadminng.HsadminNgApplication; import net.hostsharing.hsadminng.HsadminNgApplication;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository;
import net.hostsharing.hsadminng.hs.office.scenarios.contact.AddPhoneNumberToContactData; import net.hostsharing.hsadminng.hs.office.scenarios.contact.AddPhoneNumberToContactData;
import net.hostsharing.hsadminng.hs.office.scenarios.contact.AmendContactData; import net.hostsharing.hsadminng.hs.office.scenarios.contact.AmendContactData;
import net.hostsharing.hsadminng.hs.office.scenarios.contact.RemovePhoneNumberFromContactData; import net.hostsharing.hsadminng.hs.office.scenarios.contact.RemovePhoneNumberFromContactData;
@@ -39,9 +42,11 @@ import net.hostsharing.hsadminng.hs.office.scenarios.subscription.UnsubscribeFro
import net.hostsharing.hsadminng.hs.scenarios.Produces; import net.hostsharing.hsadminng.hs.scenarios.Produces;
import net.hostsharing.hsadminng.hs.scenarios.Requires; import net.hostsharing.hsadminng.hs.scenarios.Requires;
import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest; import net.hostsharing.hsadminng.hs.scenarios.ScenarioTest;
import net.hostsharing.hsadminng.lambda.Reducer;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import net.hostsharing.hsadminng.config.DisableSecurityConfig; import net.hostsharing.hsadminng.config.DisableSecurityConfig;
import net.hostsharing.hsadminng.test.IgnoreOnFailureExtension; import net.hostsharing.hsadminng.test.IgnoreOnFailureExtension;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.ClassOrderer; import org.junit.jupiter.api.ClassOrderer;
import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.MethodOrderer;
@@ -50,8 +55,10 @@ import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestClassOrder; import org.junit.jupiter.api.TestClassOrder;
import org.junit.jupiter.api.TestInfo;
import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.api.TestMethodOrder;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
@@ -71,6 +78,30 @@ import org.springframework.test.context.ActiveProfiles;
@ExtendWith(IgnoreOnFailureExtension.class) @ExtendWith(IgnoreOnFailureExtension.class)
class HsOfficeScenarioTests extends ScenarioTest { class HsOfficeScenarioTests extends ScenarioTest {
@Autowired
HsOfficePersonRbacRepository personRepo;
@SneakyThrows
@BeforeEach
protected void beforeScenario(final TestInfo testInfo) {
createHostsharingPerson();
super.beforeScenario(testInfo);
}
private void createHostsharingPerson() {
jpaAttempt.transacted(() ->
{
context.define("superuser-alex@hostsharing.net");
putAlias(
"Person: Hostsharing eG",
personRepo.findPersonByOptionalNameLike("Hostsharing eG").stream()
.map(HsOfficePersonRbacEntity::getUuid)
.reduce(Reducer::toSingleElement).orElseThrow()
);
}
);
}
@Nested @Nested
@Order(10) @Order(10)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class) @TestMethodOrder(MethodOrderer.OrderAnnotation.class)

View File

@@ -1,9 +1,6 @@
package net.hostsharing.hsadminng.hs.scenarios; package net.hostsharing.hsadminng.hs.scenarios;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository;
import net.hostsharing.hsadminng.lambda.Reducer;
import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; import net.hostsharing.hsadminng.rbac.context.ContextBasedTest;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver; import net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver;
@@ -58,15 +55,11 @@ public abstract class ScenarioTest extends ContextBasedTest {
Integer port; Integer port;
@Autowired @Autowired
HsOfficePersonRbacRepository personRepo; protected JpaAttempt jpaAttempt;
@Autowired
JpaAttempt jpaAttempt;
@SneakyThrows @SneakyThrows
@BeforeEach @BeforeEach
void beforeScenario(final TestInfo testInfo) { protected void beforeScenario(final TestInfo testInfo) {
createHostsharingPerson();
try { try {
testInfo.getTestMethod().ifPresent(currentTestMethod -> { testInfo.getTestMethod().ifPresent(currentTestMethod -> {
callRequiredProducers(currentTestMethod); callRequiredProducers(currentTestMethod);
@@ -86,20 +79,6 @@ public abstract class ScenarioTest extends ContextBasedTest {
testReport.close(); testReport.close();
} }
private void createHostsharingPerson() {
jpaAttempt.transacted(() ->
{
context.define("superuser-alex@hostsharing.net");
putAlias(
"Person: Hostsharing eG",
personRepo.findPersonByOptionalNameLike("Hostsharing eG").stream()
.map(HsOfficePersonRbacEntity::getUuid)
.reduce(Reducer::toSingleElement).orElseThrow()
);
}
);
}
@SneakyThrows @SneakyThrows
private void callRequiredProducers(final Method currentTestMethod) { private void callRequiredProducers(final Method currentTestMethod) {
final var testMethodRequires = Optional.of(currentTestMethod) final var testMethodRequires = Optional.of(currentTestMethod)
@@ -200,15 +179,15 @@ public abstract class ScenarioTest extends ContextBasedTest {
return alias; return alias;
} }
static void putAlias(final String name, final UUID value) { protected static void putAlias(final String name, final UUID value) {
aliases.put(name, value); aliases.put(name, value);
} }
static void putProperty(final String name, final Object value) { protected static void putProperty(final String name, final Object value) {
properties.put(name, (value instanceof String string) ? resolveTyped(string) : value); properties.put(name, (value instanceof String string) ? resolveTyped(string) : value);
} }
static void removeProperty(final String propName) { protected static void removeProperty(final String propName) {
properties.remove(propName); properties.remove(propName);
} }

View File

@@ -5,8 +5,10 @@ import org.apache.commons.lang3.StringUtils;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.UUID;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -14,6 +16,8 @@ import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.D
public class TemplateResolver { public class TemplateResolver {
public static final String JSON_NULL_VALUE_TO_KEEP = "NULL";
public enum Resolver { public enum Resolver {
DROP_COMMENTS, // deletes comments ('#{whatever}' -> '') DROP_COMMENTS, // deletes comments ('#{whatever}' -> '')
KEEP_COMMENTS // keep comments ('#{whatever}' -> 'whatever') KEEP_COMMENTS // keep comments ('#{whatever}' -> 'whatever')
@@ -44,6 +48,12 @@ public class TemplateResolver {
return value != null ? URLEncoder.encode(value.toString(), StandardCharsets.UTF_8) : ""; return value != null ? URLEncoder.encode(value.toString(), StandardCharsets.UTF_8) : "";
} }
}, },
JSON_ARRAY('@'){
@Override
String convert(final Object value, final Resolver resolver) {
return jsonArray(value);
}
},
COMMENT('#'){ COMMENT('#'){
@Override @Override
String convert(final Object value, final Resolver resolver) { String convert(final Object value, final Resolver resolver) {
@@ -102,13 +112,14 @@ public class TemplateResolver {
.collect(Collectors.joining("\n")); .collect(Collectors.joining("\n"));
} }
private static boolean keepLine(final String line) { private static boolean keepLine(final String line) {
final var trimmed = line.trim(); final var trimmed = line.trim();
return !trimmed.endsWith("null,") && !trimmed.endsWith("null"); return !trimmed.endsWith("null,") && !trimmed.endsWith("null");
} }
private static String keptNullValues(final String line) { private static String keptNullValues(final String line) {
return line.replace(": NULL", ": null"); return line.replace(": "+ JSON_NULL_VALUE_TO_KEEP, ": null");
} }
private String copy() { private String copy() {
@@ -163,12 +174,10 @@ public class TemplateResolver {
// => last alternative element in expression was null and not optional // => last alternative element in expression was null and not optional
throw new IllegalStateException("Missing required value in property-chain: " + nameExpression); throw new IllegalStateException("Missing required value in property-chain: " + nameExpression);
}); });
} else if (properties.containsKey(nameExpression)) {
return properties.get(nameExpression);
} else { } else {
final var val = properties.get(nameExpression); throw new IllegalStateException("Missing required property: " + nameExpression);
if (val == null) {
throw new IllegalStateException("Missing required property: " + nameExpression);
}
return val;
} }
} }
@@ -212,19 +221,40 @@ public class TemplateResolver {
private static String jsonQuoted(final Object value) { private static String jsonQuoted(final Object value) {
return switch (value) { return switch (value) {
case null -> null; case null -> "null";
case Boolean bool -> bool.toString(); case Boolean bool -> bool.toString();
case Number number -> number.toString(); case Number number -> number.toString();
case String string -> "\"" + string.replace("\n", "\\n") + "\""; case String string -> "\"" + string.replace("\n", "\\n") + "\"";
default -> "\"" + value + "\""; case UUID uuid -> "\"" + uuid + "\"";
default -> jsonObject(value);
}; };
} }
private static String jsonObject(final Object value) { private static String jsonObject(final Object value) {
return switch (value) { return switch (value) {
case null -> null; case null -> "null";
case Map<?, ?> map -> "{" + map.entrySet().stream()
.map(entry -> "\"" + entry.getKey() + "\": " + jsonQuoted(entry.getValue()))
.collect(Collectors.joining(", ")) + "}";
case String string -> "{" + string.replace("\n", " ") + "}"; case String string -> "{" + string.replace("\n", " ") + "}";
default -> throw new IllegalArgumentException("can not format " + value.getClass() + " (" + value + ") as JSON object"); default -> throw new IllegalArgumentException("can not format " + value.getClass() + " (" + value + ") as JSON object");
}; };
} }
private static String jsonArray(final Object value) {
return switch (value) {
case null -> "null";
case Object[] array -> "[" + Arrays.stream(array)
.filter(Objects::nonNull)
.map(TemplateResolver::jsonQuoted)
.collect(Collectors.joining(", ")) + "]";
case Collection<?> collection -> "[" + collection.stream()
.filter(Objects::nonNull)
.map(TemplateResolver::jsonQuoted)
.collect(Collectors.joining(", ")) + "]";
case String string -> "[" + string.replace("\n", " ") + "]";
default -> throw new IllegalArgumentException("Cannot format " + value.getClass() + " (" + value + ") as JSON array");
};
}
} }

View File

@@ -2,6 +2,9 @@ package net.hostsharing.hsadminng.hs.scenarios;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS; import static net.hostsharing.hsadminng.hs.scenarios.TemplateResolver.Resolver.DROP_COMMENTS;
@@ -12,6 +15,12 @@ class TemplateResolverUnitTest {
@Test @Test
void resolveTemplate() { void resolveTemplate() {
final var resolved = new TemplateResolver(""" final var resolved = new TemplateResolver("""
JSON arrays:
- arrayWithMixedValues: @{arrayWithMixedValues}
- arrayWithObjects: @{arrayWithObjects}
- emptyArray: @{emptyArray}
- nullArray: @{nullArray}
with optional JSON quotes: with optional JSON quotes:
${boolean}, ${boolean},
@@ -19,7 +28,7 @@ class TemplateResolverUnitTest {
${simple placeholder}, ${simple placeholder},
${nested %{name}}, ${nested %{name}},
${with-special-chars} ${with-special-chars}
and without quotes: and without quotes:
%{boolean}, %{boolean},
@@ -36,16 +45,34 @@ class TemplateResolverUnitTest {
&{nested %{name}}, &{nested %{name}},
&{with-special-chars} &{with-special-chars}
""", """,
Map.ofEntries( orderedMapOfElementsWithNullValues(
Map.entry("name", "placeholder"), entry("arrayWithMixedValues", new Object[] { "some string", true, 1234, "another string" }),
Map.entry("boolean", true), entry("arrayWithObjects", new Object[] {
Map.entry("numeric", 42), orderedMapOfElementsWithNullValues(
Map.entry("simple placeholder", "einfach"), Map.entry("name", "some name"),
Map.entry("nested placeholder", "verschachtelt"), Map.entry("number", 12345)
Map.entry("with-special-chars", "3&3 AG") ),
orderedMapOfElementsWithNullValues(
Map.entry("name", "another name"),
Map.entry("number", 98765)
)
}),
entry("emptyArray", new Object[] {}),
entry("nullArray", null),
entry("name", "placeholder"),
entry("boolean", true),
entry("numeric", 42),
entry("simple placeholder", "einfach"),
entry("nested placeholder", "verschachtelt"),
entry("with-special-chars", "3&3 AG")
)).resolve(DROP_COMMENTS); )).resolve(DROP_COMMENTS);
assertThat(resolved).isEqualTo(""" assertThat(resolved).isEqualTo("""
JSON arrays:
- arrayWithMixedValues: ["some string", true, 1234, "another string"]
- arrayWithObjects: [{"name": "some name", "number": 12345}, {"name": "another name", "number": 98765}]
- emptyArray: []
with optional JSON quotes: with optional JSON quotes:
true, true,
@@ -71,4 +98,20 @@ class TemplateResolverUnitTest {
3%263+AG 3%263+AG
""".trim()); """.trim());
} }
@SafeVarargs
private Map<String, Object> orderedMapOfElementsWithNullValues(
final Map.Entry<String, Object>... entries) {
final var map = new LinkedHashMap<String, Object>();
if (entries != null) {
Arrays.stream(entries)
.forEach(entry -> map.put(entry.getKey(), entry.getValue()));
}
return map;
}
private static AbstractMap.SimpleEntry<String, Object> entry(String key, Object value) {
return new AbstractMap.SimpleEntry<>(key, value);
}
} }

View File

@@ -449,7 +449,7 @@ class RbacSubjectControllerAcceptanceTest {
RbacSubjectEntity givenANewUser() { RbacSubjectEntity givenANewUser() {
final var givenUserName = "test-user-" + System.currentTimeMillis() + "@example.com"; final var givenUserName = "test-user-" + System.currentTimeMillis() + "@example.com";
final var givenUser = jpaAttempt.transacted(() -> { final var givenUser = jpaAttempt.transacted(() -> {
context.define(null); context.define("superuser-alex@hostsharing.net");
return rbacSubjectRepository.create(new RbacSubjectEntity(UUID.randomUUID(), givenUserName)); return rbacSubjectRepository.create(new RbacSubjectEntity(UUID.randomUUID(), givenUserName));
}).assumeSuccessful().returnedValue(); }).assumeSuccessful().returnedValue();
assertThat(rbacSubjectRepository.findByName(givenUser.getName())).isNotNull(); assertThat(rbacSubjectRepository.findByName(givenUser.getName())).isNotNull();