1
0

credentials validation (#194)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/194
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig
2025-09-01 12:13:58 +02:00
parent f1fc1203ae
commit c0991d96d9
23 changed files with 849 additions and 420 deletions
@@ -9,13 +9,24 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
class HsCredentialsContextRbacEntityUnitTest {
@Test
void toShortString() {
void toShortStringContainsJustTypeAndQualifier() {
final var entity = HsCredentialsContextRbacEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("prod")
.publicAccess(true)
.build();
assertEquals("loginContext(SSH:prod:PUBLIC)", entity.toShortString());
assertEquals("SSH:prod", entity.toShortString());
}
@Test
void toStringContainsAllNonNullFields() {
final var entity = HsCredentialsContextRbacEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("prod")
.publicAccess(true)
.build();
assertEquals("loginContext(SSH:prod:PUBLIC)", entity.toString());
}
}
@@ -9,13 +9,24 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
class HsCredentialsContextRealEntityUnitTest {
@Test
void toShortString() {
void toShortStringContainsJustTypeAndQualifier() {
final var entity = HsCredentialsContextRealEntity.builder()
.uuid(UUID.randomUUID())
.type("testType")
.qualifier("testQualifier")
.onlyForNaturalPersons(true)
.type("SSH")
.qualifier("prod")
.publicAccess(true)
.build();
assertEquals("loginContext(testType:testQualifier:NP-ONLY:INTERNAL)", entity.toShortString());
assertEquals("SSH:prod", entity.toShortString());
}
@Test
void toStringContainsAllNonNullFields() {
final var entity = HsCredentialsContextRealEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("prod")
.publicAccess(true)
.build();
assertEquals("loginContext(SSH:prod:PUBLIC)", entity.toString());
}
}
@@ -1,5 +1,6 @@
package net.hostsharing.hsadminng.hs.accounts;
import static java.util.Collections.emptyList;
import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
@@ -47,7 +48,7 @@ class HsCredentialsContextsControllerRestTest {
Context contextMock;
@Autowired
@SuppressWarnings("unused") // not used in test, but in controller class
@SuppressWarnings("unused") // not used in test but in controller class
StrictMapper mapper;
@MockitoBean
@@ -59,15 +60,12 @@ class HsCredentialsContextsControllerRestTest {
@MockitoBean
HsCredentialsContextRbacRepository loginContextRbacRepo;
@TestConfiguration
public static class TestConfig {
@Bean
public EntityManager entityManager() {
return mock(EntityManager.class);
}
}
@BeforeEach
@@ -82,18 +80,27 @@ class HsCredentialsContextsControllerRestTest {
void getListOfLoginContextsReturnsOkWithEmptyList() throws Exception {
// given
when(loginContextRbacRepo.findAll()).thenReturn(List.of(
HsCredentialsContextRbacEntity.builder()
.uuid(UUID.randomUUID())
.type("HSADMIN")
.qualifier("prod")
.build(),
HsCredentialsContextRbacEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("prod")
.build()
));
givenNoContextsInTheRepository();
// when
mockMvc.perform(MockMvcRequestBuilders
.get("/api/hs/accounts/contexts")
.header("Authorization", "Bearer superuser-alex@hostsharing.net")
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
// then
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
void getListOfLoginContextsReturnsAllContextsForGlobalAdmin() throws Exception {
// given
givenSomeContextsInTheRepository();
when(contextMock.isGlobalAdmin()).thenReturn(true);
// when
mockMvc.perform(MockMvcRequestBuilders
@@ -107,16 +114,92 @@ class HsCredentialsContextsControllerRestTest {
.andExpect(jsonPath(
"$", lenientlyEquals("""
[
{
"type": "HSADMIN",
"qualifier": "prod"
},
{
"type": "SSH",
"qualifier": "prod"
}
{
"type": "HSADMIN",
"qualifier": "prod",
"onlyForNaturalPersons": true,
"publicAccess": true
},
{
"type": "SSH",
"qualifier": "public",
"onlyForNaturalPersons": false,
"publicAccess": true
},
{
"type": "SSH",
"qualifier": "internal",
"onlyForNaturalPersons": false,
"publicAccess": false
}
]
"""
)));
}
@Test
void getListOfLoginContextsReturnsOnlyPublicContextsForNormalUser() throws Exception {
// given
givenSomeContextsInTheRepository();
when(contextMock.isGlobalAdmin()).thenReturn(false);
// when
mockMvc.perform(MockMvcRequestBuilders
.get("/api/hs/accounts/contexts")
.header("Authorization", "Bearer drew@hostsharing.org")
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
// then
.andExpect(status().isOk())
.andExpect(jsonPath(
"$", lenientlyEquals("""
[
{
"type": "HSADMIN",
"qualifier": "prod",
"onlyForNaturalPersons": true,
"publicAccess": true
},
{
"type": "SSH",
"qualifier": "public",
"onlyForNaturalPersons": false,
"publicAccess": true
}
]
"""
)));
}
private void givenNoContextsInTheRepository() {
when(loginContextRbacRepo.findAll()).thenReturn(emptyList());
}
private void givenSomeContextsInTheRepository() {
when(loginContextRbacRepo.findAll()).thenReturn(List.of(
HsCredentialsContextRbacEntity.builder()
.uuid(UUID.randomUUID())
.type("HSADMIN")
.qualifier("prod")
.publicAccess(true)
.onlyForNaturalPersons(true)
.build(),
HsCredentialsContextRbacEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("public")
.publicAccess(true)
.onlyForNaturalPersons(false)
.build(),
HsCredentialsContextRbacEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("internal")
.publicAccess(false)
.onlyForNaturalPersons(false)
.build()
));
}
}
@@ -0,0 +1,475 @@
package net.hostsharing.hsadminng.hs.accounts;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import lombok.val;
import net.hostsharing.hsadminng.HsadminNgApplication;
import net.hostsharing.hsadminng.config.DisableSecurityConfig;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.accounts.HsCredentialsEntity.HsCredentialsEntityBuilder;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectRepository;
import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.LEGAL_PERSON;
import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.NATURAL_PERSON;
import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;
@Transactional
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
classes = { HsadminNgApplication.class, DisableSecurityConfig.class, JpaAttempt.class }
)
@ActiveProfiles("test")
@Tag("generalIntegrationTest")
// too complex database interaction for just a RestTest, thus a fully integrated test
class HsCredentialsControllerAcceptanceTest extends ContextBasedTestWithCleanup {
@LocalServerPort
private Integer port;
@Autowired
Context context;
@Autowired
RbacSubjectRepository subjectRepo;
@Autowired
HsOfficePersonRealRepository realPersonRepo;
@Autowired
HsCredentialsContextRealRepository contextRepo;
@Autowired
HsCredentialsRepository credentialsRepo;
@Autowired
HsCredentialsContextRbacRepository loginContextRbacRepo;
@Autowired
JpaAttempt jpaAttempt;
@PersistenceContext
EntityManager em;
@BeforeEach
void setUp() {
context.define("superuser-alex@hostsharing.net");
}
@Nested
class GetCurrentUser {
@Test
void shouldFetchCurrentLoginUser() throws Exception {
// given
context.define("superuser-alex@hostsharing.net");
RestAssured // @formatter:off
.given()
.header("Authorization", "Bearer superuser-alex@hostsharing.net")
.port(port)
.when()
.get("http://localhost/api/hs/accounts/current")
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("subject.name", equalTo("superuser-alex@hostsharing.net"))
.body("globalAdmin", equalTo(true));
// @formatter:on
}
}
@Nested
class GetCredentialsByUuid {
@Test
void shouldFilterInvalidContextsRegardingNonNaturalPerson() {
// given
val legalPerson = givenLegalPerson("selfregistered-user-drew@hostsharing.org");
val credentialsEntity = givenNewCredentials("selfregistered-user-drew@hostsharing.org",
"test-subject1", legalPerson, builder -> {
builder.loginContexts(new HashSet<>(contextRepo.findAll()));
});
RestAssured // @formatter:off
.given()
.header("Authorization", "Bearer " + credentialsEntity.getSubject().getName())
.port(port)
.when()
.get("http://localhost/api/hs/accounts/credentials/" + credentialsEntity.getUuid())
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("$", lenientlyEquals("""
{
"person": {
"personType": "LEGAL_PERSON",
"tradeName": "Test Company",
"salutation": null,
"title": null,
"givenName": null,
"familyName": null
},
"nickname": "test-subject1",
"totpSecrets": null,
"phonePassword": null,
"emailAddress": null,
"smsNumber": null,
"active": false,
"globalUid": null,
"globalGid": null,
"onboardingToken": null,
"contexts": [
{
"uuid": "33333333-3333-3333-3333-333333333333",
"type": "SSH",
"qualifier": "external",
"onlyForNaturalPersons": false,
"publicAccess": true
},
{
"uuid": "66666666-6666-6666-6666-666666666666",
"type": "MASTODON",
"qualifier": "external",
"onlyForNaturalPersons": false,
"publicAccess": true
},
{
"uuid": "77777777-7777-7777-7777-777777777777",
"type": "BBB",
"qualifier": "external",
"onlyForNaturalPersons": false,
"publicAccess": true
}
],
"lastUsed": null
}
"""));
// @formatter:on
}
}
@Nested
class PostNewCredentials {
@Test
void shouldRejectCreatingCredentialsForUnrepresentedPerson() {
// given
val testPerson = givenPersonWithUuid("selfregistered-user-drew@hostsharing.org");
val publicContext = contextRepo.findByTypeAndQualifier("SSH", "external").orElseThrow();
assertThat(publicContext.isPublicAccess()).as("precondition failed").isTrue();
RestAssured // @formatter:off
.given()
.header("Authorization", "Bearer selfregistered-user-drew@hostsharing.org")
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"person.uuid": "%s",
"nickname": "new-user",
"active": true,
"globalUid": 30001,
"globalGid": 40001,
"contexts": [
{
"uuid" : "%s"
}
]
}
""".formatted(testPerson.getUuid(), publicContext.getUuid()))
.port(port)
.when()
.post("http://localhost/api/hs/accounts/credentials")
.then().log().all().assertThat()
.statusCode(400)
.contentType("application/json")
.body("message", containsString("wird von der eingeloggten Person nicht repräsentiert"));
// @formatter:on
}
@Test
void shouldRejectCreatingCredentialsWithPrivateContextForNormalUser() {
// given
val drewPerson = realPersonRepo.findPersonByOptionalNameLike("Drew").getFirst();
val privateInternalSshContext = contextRepo.findByTypeAndQualifier("SSH", "internal")
.map(HsCredentialsControllerAcceptanceTest::asPrivateContext).orElseThrow();
val privateInternalMatrixContext = contextRepo.findByTypeAndQualifier("MATRIX", "internal")
.map(HsCredentialsControllerAcceptanceTest::asPrivateContext).orElseThrow();
val publicExternalMatrixContext = contextRepo.findByTypeAndQualifier("MATRIX", "external")
.map(HsCredentialsControllerAcceptanceTest::asPublicContext).orElseThrow();
RestAssured // @formatter:off
.given()
.header("Authorization", "Bearer selfregistered-user-drew@hostsharing.org")
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"person.uuid": "%s",
"nickname": "new-user",
"active": true,
"globalUid": 30001,
"globalGid": 40001,
"contexts": [
{ "uuid" : "%s" },
{ "uuid" : "%s" },
{ "uuid" : "%s" }
]
}
""".formatted(
drewPerson.getUuid(),
publicExternalMatrixContext.getUuid(),
privateInternalSshContext.getUuid(),
privateInternalMatrixContext.getUuid()))
.port(port)
.when()
.post("http://localhost/api/hs/accounts/credentials")
.then().log().all().assertThat()
.statusCode(400)
.contentType("application/json")
.body("message", containsString("Kontext-Zugriff verweigert: 'MATRIX:internal', 'SSH:internal'"));
// @formatter:on
}
@Test
void shouldRejectCreatingCredentialsWithNaturalPersonRequirementForNonNaturalPerson() {
// given
val firstGmbHPerson = realPersonRepo.findPersonByOptionalNameLike("First").getFirst();
val hsadminProdContextOnlyForNaturalPersons = contextRepo.findByTypeAndQualifier("HSADMIN", "prod")
.map(HsCredentialsControllerAcceptanceTest::asNaturalPersonContext).orElseThrow();
RestAssured // @formatter:off
.given()
.header("Authorization", "Bearer superuser-alex@hostsharing.net")
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"person.uuid": "%s",
"nickname": "new-user",
"active": true,
"globalUid": 30001,
"globalGid": 40001,
"contexts": [
{ "uuid" : "%s" }
]
}
""".formatted(
firstGmbHPerson.getUuid(),
hsadminProdContextOnlyForNaturalPersons.getUuid()))
.port(port)
.when()
.post("http://localhost/api/hs/accounts/credentials")
.then().log().all().assertThat()
.statusCode(400)
.contentType("application/json")
.body("message", containsString("Kontext verlangt eine natürliche Person: 'HSADMIN:prod'"));
// @formatter:on
}
}
@Nested
class PatchCredentials {
@Test
void shouldRejectPatchingCredentialsWithPrivateContextForNormalUser() {
// given
context.define("selfregistered-user-drew@hostsharing.org");
val drewCredentialsUuid = credentialsRepo.findByCurrentSubject().stream().findFirst().orElseThrow()
.getSubject().getUuid();
val privateInternalSshContext = contextRepo.findByTypeAndQualifier("SSH", "internal")
.map(HsCredentialsControllerAcceptanceTest::asPrivateContext).orElseThrow();
val privateInternalMatrixContext = contextRepo.findByTypeAndQualifier("MATRIX", "internal")
.map(HsCredentialsControllerAcceptanceTest::asPrivateContext).orElseThrow();
val publicExternalMatrixContext = contextRepo.findByTypeAndQualifier("MATRIX", "external")
.map(HsCredentialsControllerAcceptanceTest::asPublicContext).orElseThrow();
RestAssured // @formatter:off
.given()
.header("Authorization", "Bearer selfregistered-user-drew@hostsharing.org")
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"contexts": [
{ "uuid" : "%s" },
{ "uuid" : "%s" },
{ "uuid" : "%s" }
]
}
""".formatted(
privateInternalSshContext.getUuid(),
publicExternalMatrixContext.getUuid(),
privateInternalMatrixContext.getUuid()))
.port(port)
.when()
.patch("http://localhost/api/hs/accounts/credentials/" + drewCredentialsUuid)
.then().log().all().assertThat()
.statusCode(400)
.contentType("application/json")
.body("message", containsString("Kontext-Zugriff verweigert: 'MATRIX:internal', 'SSH:internal'"));
// @formatter:on
}
@Test
void shouldRejectPatchingCredentialsAndRemovingTheOwnHsadminCredentials() {
// given
context.define("selfregistered-user-drew@hostsharing.org");
val drewCredentialsUuid = credentialsRepo.findByCurrentSubject().stream().findFirst().orElseThrow()
.getSubject().getUuid();
val publicExternalMatrixContext = contextRepo.findByTypeAndQualifier("MATRIX", "external")
.map(HsCredentialsControllerAcceptanceTest::asPublicContext).orElseThrow();
RestAssured // @formatter:off
.given()
.header("Authorization", "Bearer selfregistered-user-drew@hostsharing.org")
.header("Accept-Language", "de")
.contentType(ContentType.JSON)
.body("""
{
"contexts": [
{ "uuid" : "%s" }
]
}
""".formatted(publicExternalMatrixContext.getUuid()))
.port(port)
.when()
.patch("http://localhost/api/hs/accounts/credentials/" + drewCredentialsUuid)
.then().log().all().assertThat()
.statusCode(400)
.contentType("application/json")
.body("message", containsString("die eigenen hsadmin-Credentials dürfen nicht entfernt werden"));
// @formatter:on
}
}
@Nested
class MarkCredentialsAsUsed {
@Test
void markCredentialsAsUsed() {
// given
val testPerson = givenNaturalPerson("selfregistered-user-drew@hostsharing.org");
val credentialsEntity = givenNewCredentials("selfregistered-user-drew@hostsharing.org",
"test-subject2",
testPerson, builder -> {
builder.onboardingToken("some-onboarding-token");
builder.loginContexts(contextRepo.findAll().stream()
.filter(HsCredentialsContext::isPublicAccess).collect(Collectors.toSet()));
});
RestAssured // @formatter:off
.given()
.header("Authorization", "Bearer superuser-alex@hostsharing.net")
.port(port)
.when()
.post("http://localhost/api/hs/accounts/credentials/" + credentialsEntity.getUuid() + "/used")
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("uuid", is(credentialsEntity.getUuid().toString()))
.body("onboardingToken", is(nullValue()))
.body("lastUsed", is(not(nullValue())));
// @formatter:on
}
}
// Helper methods
private HsOfficePersonRealEntity givenLegalPerson(final String executingSubjectName) {
return jpaAttempt.transacted(() -> {
context.define(executingSubjectName);
return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder()
.personType(LEGAL_PERSON)
.tradeName("Test Company")
.build()));
}).assertSuccessful().returnedValue();
}
private HsOfficePersonRealEntity givenNaturalPerson(final String executingSubjectName) {
return jpaAttempt.transacted(() -> {
context.define(executingSubjectName);
return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder()
.personType(NATURAL_PERSON)
.familyName("Test")
.givenName("User")
.build()));
}).assertSuccessful().returnedValue();
}
private HsOfficePersonRealEntity givenPersonWithUuid(final String executingSubjectName) {
return jpaAttempt.transacted(() -> {
context.define(executingSubjectName);
return toCleanup(realPersonRepo.save(HsOfficePersonRealEntity.builder()
.personType(NATURAL_PERSON)
.familyName("Test")
.givenName("Person")
.build()));
}).returnedValue();
}
private static HsCredentialsContextRealEntity asNaturalPersonContext(@NotNull HsCredentialsContextRealEntity context) {
assertThat(context.isOnlyForNaturalPersons()).as("precondition failed").isTrue();
return context;
}
private static HsCredentialsContextRealEntity asPrivateContext(@NotNull HsCredentialsContextRealEntity context) {
assertThat(context.isPublicAccess()).as("precondition failed").isFalse();
return context;
}
private static HsCredentialsContextRealEntity asPublicContext(@NotNull HsCredentialsContextRealEntity context) {
assertThat(context.isPublicAccess()).as("precondition failed").isTrue();
return context;
}
private HsCredentialsEntity givenNewCredentials(
final String executingSubjectName,
final String newSubjectName, final HsOfficePersonRealEntity person,
final Consumer<HsCredentialsEntityBuilder> modifier
) {
return jpaAttempt.transacted(() -> {
context.define(executingSubjectName);
final RbacSubjectEntity rbacSubjectEntity = RbacSubjectEntity.builder()
.name(newSubjectName)
.build();
val subject = subjectRepo.create(rbacSubjectEntity);
context.define(subject.getName());
val attachedPerson = em.find(HsOfficePersonRealEntity.class, person.getUuid());
val credentialsBuilder = HsCredentialsEntity.builder()
.person(attachedPerson)
.subject(subjectRepo.findByUuid(subject.getUuid()))
.loginContexts(Set.of());
modifier.accept(credentialsBuilder);
return toCleanup(credentialsRepo.save(credentialsBuilder.build()));
}).assertSuccessful().returnedValue();
}
}
@@ -1,314 +0,0 @@
package net.hostsharing.hsadminng.hs.accounts;
import net.hostsharing.hsadminng.config.DisableSecurityConfig;
import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration;
import net.hostsharing.hsadminng.config.MessageTranslator;
import net.hostsharing.hsadminng.config.MessagesResourceConfig;
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.HsOfficePersonRealRepository;
import net.hostsharing.hsadminng.mapper.StrictMapper;
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.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import jakarta.persistence.EntityManagerFactory;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.LEGAL_PERSON;
import static net.hostsharing.hsadminng.hs.office.person.HsOfficePersonType.NATURAL_PERSON;
import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(HsCredentialsController.class)
@Import({
StrictMapper.class,
JsonObjectMapperConfiguration.class,
DisableSecurityConfig.class,
// HOWTO: test i18n translations
MessagesResourceConfig.class,
MessageTranslator.class })
@ActiveProfiles("test")
class HsCredentialsControllerRestTest {
private static final UUID PERSON_UUID = UUID.randomUUID();
@Autowired
MockMvc mockMvc;
@MockitoBean
Context contextMock;
@Autowired
@SuppressWarnings("unused") // not used in test, but in controller class
StrictMapper mapper;
@MockitoBean
EntityManagerWrapper em;
@MockitoBean
EntityManagerFactory emf;
@MockitoBean
RbacSubjectRepository subjectRepo;
@MockitoBean
HsOfficePersonRealRepository realPersonRepo;
@MockitoBean
HsOfficePersonRbacRepository rbacPersonRepo;
@MockitoBean
HsCredentialsContextRbacRepository loginContextRbacRepo;
@MockitoBean
HsCredentialsRepository credentialsRepo;
@MockitoBean
CredentialContextResourceToEntityMapper contextMapper;
@Test
void shouldFetchCurrentLoginUser() throws Exception {
// given
final UUID currentSubjectUuid = UUID.randomUUID();
given(contextMock.fetchCurrentSubjectUuid()).willReturn(currentSubjectUuid);
given(contextMock.isGlobalAdmin()).willReturn(true);
given(subjectRepo.findByUuid(currentSubjectUuid)).willReturn(
RbacSubjectEntity.builder().uuid(currentSubjectUuid).name("test-user").build()
);
given(credentialsRepo.findByUuid(currentSubjectUuid)).willReturn(
Optional.of(HsCredentialsEntity.builder()
.uuid(currentSubjectUuid)
.person(HsOfficePersonRbacEntity.builder()
.uuid(PERSON_UUID)
.personType(NATURAL_PERSON)
.familyName("Miller")
.givenName("Steph")
.build())
.subject(RbacSubjectEntity.builder().name("steph-miller").build())
.build())
);
// when
mockMvc.perform(MockMvcRequestBuilders
.get("/api/hs/accounts/current")
.header("Authorization", "Bearer test")
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
// then
.andExpect(status().isOk())
.andExpect(jsonPath("$.subject.uuid").value(currentSubjectUuid.toString()))
.andExpect(jsonPath("$.subject.name").value("test-user"))
.andExpect(jsonPath("$.person.uuid").value(PERSON_UUID.toString()))
.andExpect(jsonPath("$.person.familyName").value("Miller"))
.andExpect(jsonPath("$.person.givenName").value("Steph"))
.andExpect(jsonPath("$.globalAdmin").value(true));
}
@Test
void shouldFilterInvalidContextsRegardingNonNaturalPerson() throws Exception {
// given
final var givenCredentialsUuid = UUID.randomUUID();
final var contextForNP = HsCredentialsContextRealEntity.builder()
.uuid(UUID.randomUUID())
.type("HSADMIN")
.qualifier("prod")
.onlyForNaturalPersons(true)
.build();
final var contextForAll = HsCredentialsContextRealEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("prod")
.onlyForNaturalPersons(false)
.build();
final var credentialsEntity = HsCredentialsEntity.builder()
.uuid(givenCredentialsUuid)
.person(HsOfficePersonRbacEntity.builder()
.uuid(PERSON_UUID)
.personType(LEGAL_PERSON)
.build())
.subject(RbacSubjectEntity.builder().name("some-nickname").build())
.loginContexts(Set.of(contextForNP, contextForAll))
.build();
when(credentialsRepo.findByUuid(givenCredentialsUuid))
.thenReturn(Optional.of(credentialsEntity));
// when
mockMvc.perform(MockMvcRequestBuilders
.get("/api/hs/accounts/credentials/" + givenCredentialsUuid)
.header("Authorization", "Bearer test")
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
// then
.andExpect(status().isOk())
.andExpect(jsonPath("$.contexts.length()").value(1))
.andExpect(jsonPath("$.contexts[0].type").value("SSH"))
.andExpect(jsonPath("$.contexts[0].qualifier").value("prod"))
.andExpect(jsonPath("$.contexts[0].onlyForNaturalPersons").value(false));
}
@Test
void shouldRejectCreatingCredentialsForUnrepresentedPerson() throws Exception {
// given
final var personUuid = UUID.randomUUID();
final AtomicReference<RbacSubjectEntity> createdSubject = new AtomicReference<>();
given(subjectRepo.create(any())).willAnswer(invocation -> {
final var passedEntity = (RbacSubjectEntity) invocation.getArgument(0);
passedEntity.setUuid(UUID.randomUUID());
createdSubject.set(passedEntity); // Capture the instance
return passedEntity;
});
given(contextMock.fetchCurrentSubject()).willAnswer(invocation -> createdSubject.get().getName());
given(subjectRepo.findByUuid(any())).willAnswer(invocation -> createdSubject.get());
given(rbacPersonRepo.findByUuid(personUuid)).willReturn(Optional.of(
HsOfficePersonRbacEntity.builder().uuid(personUuid).personType(NATURAL_PERSON).build()
));
given(rbacPersonRepo.findPersonsRepresentedByPersonWithUuid(personUuid)).willReturn(List.of(
// some persons, but not the one from the login-user itself
HsOfficePersonRbacEntity.builder().uuid(UUID.randomUUID()).personType(NATURAL_PERSON).build(),
HsOfficePersonRbacEntity.builder().uuid(UUID.randomUUID()).personType(LEGAL_PERSON).build()
));
final var givenCredentialsUuid = UUID.randomUUID();
final var contextForNP = HsCredentialsContextRealEntity.builder()
.uuid(UUID.randomUUID())
.type("HSADMIN")
.qualifier("prod")
.onlyForNaturalPersons(true)
.build();
final var contextForAll = HsCredentialsContextRealEntity.builder()
.uuid(UUID.randomUUID())
.type("SSH")
.qualifier("prod")
.onlyForNaturalPersons(false)
.build();
final var credentialsEntity = HsCredentialsEntity.builder()
.uuid(givenCredentialsUuid)
.person(HsOfficePersonRbacEntity.builder()
.uuid(PERSON_UUID)
.personType(LEGAL_PERSON)
.build())
.subject(RbacSubjectEntity.builder().name("some-nickname").build())
.loginContexts(Set.of(contextForNP, contextForAll))
.build();
when(credentialsRepo.findByUuid(givenCredentialsUuid))
.thenReturn(Optional.of(credentialsEntity));
// when
mockMvc.perform(MockMvcRequestBuilders
.post("/api/hs/accounts/credentials")
.header("Authorization", "Bearer test")
// HOWTO: test i18n translations
.header("Accept-Language", "de")
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{
"person.uuid": "${personUuid}",
"nickname": "${nickname}",
"active": true,
"globalUid": 30001,
"globalGid": 40001,
"contexts": [
{
"uuid" : "11111111-1111-1111-1111-111111111111",
"type" : "HSADMIN",
"qualifier" : "prod",
"onlyForNaturalPersons" : true,
"publicAccess" : true
}
]
}
"""
.replace("${personUuid}", personUuid.toString())
.replace("${nickname}", "new-user")
)
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
// then
.andExpect(status().is4xxClientError())
.andExpect(jsonPath("$.message", containsString(
"Zugriff verweigert: personUuid \"${personUuid}\" wird von der eingeloggten Person nicht repräsentiert"
.replace("${personUuid}", personUuid.toString()))));
}
@Test
void markCredentialsAsUsed() throws Exception {
// given
final var givenCredentialsUuid = UUID.randomUUID();
when(credentialsRepo.findByUuid(givenCredentialsUuid)).thenReturn(Optional.of(
HsCredentialsEntity.builder()
.uuid(givenCredentialsUuid)
.person(HsOfficePersonRbacEntity.builder().uuid(PERSON_UUID).build())
.subject(RbacSubjectEntity.builder().name("some-nickname").build())
.lastUsed(null)
.onboardingToken("fake-onboarding-token")
.build()
));
when(credentialsRepo.save(any())).thenAnswer(invocation ->
invocation.getArgument(0)
);
// when
mockMvc.perform(MockMvcRequestBuilders
.post("/api/hs/accounts/credentials/%{credentialsUuid}/used"
.replace("%{credentialsUuid}", givenCredentialsUuid.toString()))
.header("Authorization", "Bearer superuser-alex@hostsharing.net")
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
// then
.andExpect(status().isOk())
.andExpect(jsonPath(
"$", lenientlyEquals("""
{
"uuid": "%{credentialsUuid}",
"onboardingToken": null
}
""".replace("%{credentialsUuid}", givenCredentialsUuid.toString())
)))
.andExpect(jsonPath("$.lastUsed").value(new CustomMatcher<String>("lastUsed should have recent timestamp") {
@Override
public boolean matches(final Object o) {
if (o == null) {
return false;
}
final var lastUsed = ZonedDateTime.parse(o.toString(), DateTimeFormatter.ISO_DATE_TIME)
.toLocalDateTime();
return lastUsed.isAfter(LocalDateTime.now().minusMinutes(1)) &&
lastUsed.isBefore(LocalDateTime.now());
}
}));
}
}
@@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.hs.accounts;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealRepository;
import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationRealEntity;
@@ -62,8 +62,8 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTestWithCleanup
private RbacSubjectEntity alexSubject;
private RbacSubjectEntity drewSubject;
private RbacSubjectEntity testUserSubject;
private HsOfficePersonRbacEntity drewPerson;
private HsOfficePersonRbacEntity testUserPerson;
private HsOfficePersonRealEntity drewPerson;
private HsOfficePersonRealEntity testUserPerson;
@BeforeEach
void setUp() {
@@ -277,13 +277,13 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTestWithCleanup
}
}
private HsOfficePersonRbacEntity fetchPersonByGivenName(final String givenName) {
final String jpql = "SELECT p FROM HsOfficePersonRbacEntity p WHERE p.givenName = :givenName";
final Query query = em.createQuery(jpql, HsOfficePersonRbacEntity.class);
private HsOfficePersonRealEntity fetchPersonByGivenName(final String givenName) {
final String jpql = "SELECT p FROM HsOfficePersonRealEntity p WHERE p.givenName = :givenName";
final Query query = em.createQuery(jpql, HsOfficePersonRealEntity.class);
query.setParameter("givenName", givenName);
try {
context(SUPERUSER_ALEX_SUBJECT_NAME);
return notNull((HsOfficePersonRbacEntity) query.getSingleResult());
return notNull((HsOfficePersonRealEntity) query.getSingleResult());
} catch (final NoResultException e) {
throw new AssertionError(
"Failed to find person with name '" + givenName + "'. Ensure test data is present.", e);
@@ -315,7 +315,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTestWithCleanup
private class RelationBuilder {
private final HsOfficeRelationType relationType;
private HsOfficePersonRealEntity anchorPerson;
private HsOfficePersonRbacEntity holderPerson;
private HsOfficePersonRealEntity holderPerson;
private HsOfficeContactRealEntity contact;
public RelationBuilder(HsOfficeRelationType relationType) {
@@ -327,7 +327,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTestWithCleanup
return this;
}
public RelationBuilder withHolder(HsOfficePersonRbacEntity holderPerson) {
public RelationBuilder withHolder(HsOfficePersonRealEntity holderPerson) {
this.holderPerson = holderPerson;
return this;
}
@@ -373,7 +373,7 @@ class HsCredentialsRepositoryIntegrationTest extends ContextBasedTestWithCleanup
final var credentials = HsCredentialsEntity.builder()
.uuid(subject.getUuid())
.subject(subject)
.person(em.find(HsOfficePersonRbacEntity.class, person.getUuid()))
.person(em.find(HsOfficePersonRealEntity.class, person.getUuid()))
.emailAddress(emailAddress)
.active(true)
.build();
@@ -150,6 +150,39 @@ class HsOfficePersonRealRepositoryIntegrationTest extends ContextBasedTestWithCl
}
}
@Test
public void findPersonsRepresentedByPersonWithUuid() {
// given
context("superuser-alex@hostsharing.net");
final var personUuid = personRealRepo.findPersonByOptionalNameLike("Fouler").getFirst().getUuid();
// when
@SuppressWarnings("unchecked") final List<HsOfficePersonRealEntity> representedPersons = personRealRepo.findPersonsRepresentedByPersonWithUuid(personUuid);
// then
assertThat(representedPersons).map(Object::toString).containsExactlyInAnyOrder(
"person(personType=NP, familyName='Fouler', givenName='Ellie')",
"person(personType=LP, tradeName='Fourth eG')"
);
}
@Test
public void findPersonsRepresentedByPersonWithUuidDrew() {
// given
context("superuser-alex@hostsharing.net");
final var personUuid = personRealRepo.findPersonByOptionalNameLike("Drew").getFirst().getUuid();
// when
@SuppressWarnings("unchecked") final List<HsOfficePersonRealEntity> representedPersons = personRealRepo.findPersonsRepresentedByPersonWithUuid(personUuid);
// then
assertThat(representedPersons).map(Object::toString).containsExactlyInAnyOrder(
"person(personType=NP, familyName='User', givenName='Drew')"
);
}
@Test
public void auditJournalLogIsAvailable() {
// given
@@ -157,7 +190,7 @@ class HsOfficePersonRealRepositoryIntegrationTest extends ContextBasedTestWithCl
select currentTask, targetTable, targetOp, targetdelta->>'tradename', targetdelta->>'lastname'
from base.tx_journal_v
where targettable = 'hs_office.person';
""");
""");
// when
@SuppressWarnings("unchecked") final List<Object[]> customerLogEntries = query.getResultList();
@@ -10,7 +10,6 @@ import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
public class EntityManagerWrapperFake extends EntityManagerWrapper {
private Map<Class<?>, Map<Object, Object>> entityClasses = new HashMap<>();
@@ -5,6 +5,7 @@ import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.mapper.StrictMapper;
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
import net.hostsharing.hsadminng.config.DisableSecurityConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@@ -19,7 +20,9 @@ import java.util.UUID;
import static net.hostsharing.hsadminng.rbac.test.IsValidUuidMatcher.isUuidValid;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -42,6 +45,12 @@ class RbacSubjectControllerRestTest {
@MockitoBean
EntityManagerWrapper em;
@BeforeEach
void beforeEach() {
given(rbacSubjectRepository.create(any())).willAnswer(invocation ->
invocation.<RbacSubjectEntity>getArgument(0)
);
}
@Test
void postNewSubjectUsesGivenUuid() throws Exception {
@@ -53,7 +53,7 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest {
@Autowired
JpaAttempt jpaAttempt;
private TreeMap<UUID, Class<? extends ImmutableBaseEntity>> entitiesToCleanup = new TreeMap<>();
private LinkedHashMap<UUID, Class<? extends ImmutableBaseEntity>> entitiesToCleanup = new LinkedHashMap<>();
private static Long latestIntialTestDataSerialId;
private static boolean countersInitialized = false;
@@ -102,6 +102,10 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest {
? tableName.substring(0, tableName.length() - "_rv".length())
: tableName;
final var rawTableName = rvTableName.endsWith("_rv")
? rvTableName.substring(0, rvTableName.length() - "_rv".length())
: rvTableName;
allRbacObjects().stream()
.filter(o -> o.startsWith(rvTableName + ":"))
.filter(o -> !initialRbacObjects.contains(o))
@@ -191,11 +195,11 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest {
context.define("superuser-alex@hostsharing.net", null);
entitiesToCleanup.reversed().forEach((uuid, entityClass) -> {
final var rvTableName = entityClass.getAnnotation(Table.class).name();
if ( !rvTableName.endsWith("_rv") ) {
throw new IllegalStateException();
}
final var rawTableName = rvTableName.substring(0, rvTableName.length() - "_rv".length());
final var deletedRows = em.createNativeQuery("DELETE FROM " + rawTableName + " WHERE uuid=:uuid")
final var scope = entityClass.getAnnotation(Table.class).schema();
final var rawTableName = rvTableName.endsWith("_rv")
? rvTableName.substring(0, rvTableName.length() - "_rv".length())
: rvTableName;
final var deletedRows = em.createNativeQuery("DELETE FROM " + scope + "." + rawTableName + " WHERE uuid=:uuid")
.setParameter("uuid", uuid).executeUpdate();
out.println("DELETING temporary " + entityClass.getSimpleName() + "#" + uuid + " deleted " + deletedRows + " rows");
});
@@ -264,6 +268,9 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest {
assertThat(after).isNotNull();
final SetUtils.SetView<String> difference = difference(before, after);
assertThat(difference).as("missing entities (deleted initial test data)").isEmpty();
difference(after, before).stream().iterator().forEachRemaining(e -> {
em.remove(e);
});
assertThat(difference(after, before)).as("spurious entities (test data not cleaned up by this test)").isEmpty();
}