login-credentials without RBAC (#173)
Co-authored-by: Michael Hoennig <michael@hoennig.de> Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/173 Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
6
.aliases
6
.aliases
@@ -73,6 +73,12 @@ function importLegacyData() {
|
||||
}
|
||||
alias gw-importHostingAssets='importLegacyData importHostingAssets'
|
||||
|
||||
function gradlewBootRun() {
|
||||
echo gw bootRun --args="--spring.profiles.active=dev,fakeCasAuthenticator,complete,test-data --server.port=${1:-8080}"
|
||||
./gradlew bootRun --args="--spring.profiles.active=dev,fakeCasAuthenticator,complete,test-data --server.port=${1:-8080}"
|
||||
}
|
||||
alias gw-bootRun=gradlewBootRun
|
||||
|
||||
alias podman-start='systemctl --user enable --now podman.socket && systemctl --user status podman.socket && ls -la /run/user/$UID/podman/podman.sock'
|
||||
alias podman-stop='systemctl --user disable --now podman.socket && systemctl --user status podman.socket && ls -la /run/user/$UID/podman/podman.sock'
|
||||
alias podman-use='export DOCKER_HOST="unix:///run/user/$UID/podman/podman.sock"; export TESTCONTAINERS_RYUK_DISABLED=true'
|
||||
|
@@ -95,6 +95,9 @@ Next, compile and run the application on `localhost:8080` and the management ser
|
||||
# this runs the application with test-data and all modules:
|
||||
gw bootRun --args='--spring.profiles.active=dev,fakeCasAuthenticator,complete,test-data'
|
||||
|
||||
# there is also an alias which takes an optional port as an argument:
|
||||
gw-bootRun 8888
|
||||
|
||||
The meaning of these profiles is:
|
||||
|
||||
- **dev**: the PostgreSQL users are created via Liquibase
|
||||
|
@@ -209,12 +209,22 @@ openapiProcessor {
|
||||
process("springHsHosting") {
|
||||
processorName("spring")
|
||||
processor("io.openapiprocessor:openapi-processor-spring:2022.5")
|
||||
apiPath(project.file("src/main/resources/api-definition/hs-hosting//hs-hosting.yaml").path)
|
||||
apiPath(project.file("src/main/resources/api-definition/hs-hosting/hs-hosting.yaml").path)
|
||||
prop("mapping", project.file("src/main/resources/api-definition/hs-hosting/api-mappings.yaml").path)
|
||||
prop("showWarnings", true)
|
||||
prop("openApiNullable", true)
|
||||
targetDir(layout.buildDirectory.dir("generated/sources/openapi-javax").get().asFile.path)
|
||||
}
|
||||
|
||||
process("springCredentials") {
|
||||
processorName("spring")
|
||||
processor("io.openapiprocessor:openapi-processor-spring:2022.5")
|
||||
apiPath(project.file("src/main/resources/api-definition/credentials/api-paths.yaml").path)
|
||||
prop("mapping", project.file("src/main/resources/api-definition/credentials/api-mappings.yaml").path)
|
||||
prop("showWarnings", true)
|
||||
prop("openApiNullable", true)
|
||||
targetDir(layout.buildDirectory.dir("generated/sources/openapi-javax").get().asFile.path)
|
||||
}
|
||||
}
|
||||
|
||||
// Add generated sources to the main source set
|
||||
@@ -234,7 +244,8 @@ val processSpring = tasks.register("processSpring") {
|
||||
"processSpringTest",
|
||||
"processSpringHsOffice",
|
||||
"processSpringHsBooking",
|
||||
"processSpringHsHosting"
|
||||
"processSpringHsHosting",
|
||||
"processSpringCredentials"
|
||||
)
|
||||
}
|
||||
|
||||
|
49
doc/ideas/login-credentials-data-model.mermaid
Normal file
49
doc/ideas/login-credentials-data-model.mermaid
Normal file
@@ -0,0 +1,49 @@
|
||||
classDiagram
|
||||
direction LR
|
||||
|
||||
OfficePerson o.. "*" LoginCredentials
|
||||
LoginCredentials "1" o-- "1" RbacSubject
|
||||
|
||||
LoginContext "1..n" --o "1" LoginContextMapping
|
||||
LoginCredentials "1..n" --o "1" LoginContextMapping
|
||||
|
||||
class LoginCredentials{
|
||||
+twoFactorAuth: text
|
||||
+telephonePassword: text
|
||||
+emailAdress: text
|
||||
+smsNumber: text
|
||||
-active: bool [r/w]
|
||||
-globalUid: int [w/o]
|
||||
-globalGid: int [w/o]
|
||||
-onboardingToken: text [w/o]
|
||||
}
|
||||
|
||||
class LoginContext{
|
||||
-type: Enum [SSH, Matrix, Mastodon, ...]
|
||||
-qualifier: text
|
||||
}
|
||||
|
||||
class LoginContextMapping{
|
||||
}
|
||||
note for LoginContextMapping "Assigns LoginCredentials to LoginContexts"
|
||||
|
||||
class RbacSubject{
|
||||
+uuid: uuid
|
||||
+name: text # == nickname
|
||||
}
|
||||
|
||||
class OfficePerson{
|
||||
+type: enum
|
||||
+tradename: text
|
||||
+title: text
|
||||
+familyName: text
|
||||
+givenName: text
|
||||
+salutation: text
|
||||
}
|
||||
|
||||
style LoginContext fill:#00f,color:#fff
|
||||
style LoginContextMapping fill:#00f,color:#fff
|
||||
style LoginCredentials fill:#00f,color:#fff
|
||||
|
||||
style RbacSubject fill:#f96,color:#fff
|
||||
style OfficePerson fill:#f66,color:#000
|
@@ -0,0 +1,62 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import net.hostsharing.hsadminng.persistence.BaseEntity;
|
||||
import net.hostsharing.hsadminng.repr.Stringify;
|
||||
import net.hostsharing.hsadminng.repr.Stringifyable;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static net.hostsharing.hsadminng.repr.Stringify.stringify;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@AllArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@SuperBuilder(builderMethodName = "baseBuilder", toBuilder = true)
|
||||
@MappedSuperclass
|
||||
public abstract class HsCredentialsContext implements Stringifyable, BaseEntity<HsCredentialsContext> {
|
||||
|
||||
private static Stringify<HsCredentialsContext> stringify = stringify(HsCredentialsContext.class, "loginContext")
|
||||
.withProp(HsCredentialsContext::getType)
|
||||
.withProp(HsCredentialsContext::getQualifier)
|
||||
.quotedValues(false)
|
||||
.withSeparator(":");
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
@Column(name = "uuid", nullable = false, updatable = false)
|
||||
private UUID uuid;
|
||||
|
||||
@NotNull
|
||||
@Column
|
||||
private int version;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "type", length = 16)
|
||||
private String type;
|
||||
|
||||
@Column(name = "qualifier", length = 80)
|
||||
private String qualifier;
|
||||
|
||||
@Override
|
||||
public String toShortString() {
|
||||
return toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return stringify.apply(this);
|
||||
}
|
||||
}
|
@@ -0,0 +1,55 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import jakarta.persistence.AttributeOverride;
|
||||
import jakarta.persistence.AttributeOverrides;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
import net.hostsharing.hsadminng.rbac.generator.RbacSpec;
|
||||
import net.hostsharing.hsadminng.rbac.generator.RbacSpec.SQL;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.GLOBAL;
|
||||
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Permission.*;
|
||||
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.GUEST;
|
||||
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.OWNER;
|
||||
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.ADMIN;
|
||||
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.Role.REFERRER;
|
||||
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.WITHOUT_IMPLICIT_GRANTS;
|
||||
import static net.hostsharing.hsadminng.rbac.generator.RbacSpec.rbacViewFor;
|
||||
|
||||
@Entity
|
||||
@Table(schema = "hs_credentials", name = "context") // TODO_impl: RBAC rules for _rv do not yet work properly
|
||||
@SuperBuilder(toBuilder = true)
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(name = "uuid", column = @Column(name = "uuid"))
|
||||
})
|
||||
public class HsCredentialsContextRbacEntity extends HsCredentialsContext {
|
||||
|
||||
// TODO_impl: RBAC rules for _rv do not yet work properly (remove the X)
|
||||
public static RbacSpec rbacX() {
|
||||
return rbacViewFor("credentialsContext", HsCredentialsContextRbacEntity.class)
|
||||
.withIdentityView(SQL.projection("type || ':' || qualifier"))
|
||||
.withRestrictedViewOrderBy(SQL.expression("type || ':' || qualifier"))
|
||||
.withoutUpdatableColumns()
|
||||
.createRole(OWNER, WITHOUT_IMPLICIT_GRANTS)
|
||||
.createSubRole(ADMIN, WITHOUT_IMPLICIT_GRANTS)
|
||||
.createSubRole(REFERRER, WITHOUT_IMPLICIT_GRANTS)
|
||||
.toRole(GLOBAL, ADMIN).grantPermission(INSERT)
|
||||
.toRole(GLOBAL, ADMIN).grantPermission(DELETE)
|
||||
.toRole(GLOBAL, GUEST).grantPermission(SELECT);
|
||||
}
|
||||
|
||||
// TODO_impl: RBAC rules for _rv do not yet work properly (remove the X)
|
||||
public static void mainX(String[] args) throws IOException {
|
||||
rbacX().generateWithBaseFileName("9-hs-global/950-credentials/9513-hs-credentials-rbac");
|
||||
}
|
||||
}
|
@@ -0,0 +1,24 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import io.micrometer.core.annotation.Timed;
|
||||
import org.springframework.data.repository.Repository;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface HsCredentialsContextRbacRepository extends Repository<HsCredentialsContextRbacEntity, UUID> {
|
||||
|
||||
@Timed("app.login.context.repo.findAll")
|
||||
List<HsCredentialsContextRbacEntity> findAll();
|
||||
|
||||
@Timed("app.login.context.repo.findByUuid")
|
||||
Optional<HsCredentialsContextRbacEntity> findByUuid(final UUID id);
|
||||
|
||||
@Timed("app.login.context.repo.findByTypeAndQualifier")
|
||||
Optional<HsCredentialsContextRbacEntity> findByTypeAndQualifier(@NotNull String contextType, @NotNull String qualifier);
|
||||
|
||||
@Timed("app.login.context.repo.save")
|
||||
HsCredentialsContextRbacEntity save(final HsCredentialsContextRbacEntity entity);
|
||||
}
|
@@ -0,0 +1,23 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import jakarta.persistence.AttributeOverride;
|
||||
import jakarta.persistence.AttributeOverrides;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.SuperBuilder;
|
||||
|
||||
@Entity
|
||||
@Table(schema = "hs_credentials", name = "context")
|
||||
@SuperBuilder(toBuilder = true)
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AttributeOverrides({
|
||||
@AttributeOverride(name = "uuid", column = @Column(name = "uuid"))
|
||||
})
|
||||
public class HsCredentialsContextRealEntity extends HsCredentialsContext {
|
||||
}
|
@@ -0,0 +1,24 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import io.micrometer.core.annotation.Timed;
|
||||
import org.springframework.data.repository.Repository;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface HsCredentialsContextRealRepository extends Repository<HsCredentialsContextRealEntity, UUID> {
|
||||
|
||||
@Timed("app.login.context.repo.findAll")
|
||||
List<HsCredentialsContextRealEntity> findAll();
|
||||
|
||||
@Timed("app.login.context.repo.findByUuid")
|
||||
Optional<HsCredentialsContextRealEntity> findByUuid(final UUID id);
|
||||
|
||||
@Timed("app.login.context.repo.findByTypeAndQualifier")
|
||||
Optional<HsCredentialsContextRealEntity> findByTypeAndQualifier(@NotNull String contextType, @NotNull String qualifier);
|
||||
|
||||
@Timed("app.login.context.repo.save")
|
||||
HsCredentialsContextRealEntity save(final HsCredentialsContextRealEntity entity);
|
||||
}
|
@@ -0,0 +1,33 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import net.hostsharing.hsadminng.context.Context;
|
||||
import net.hostsharing.hsadminng.credentials.generated.api.v1.api.LoginContextsApi;
|
||||
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource;
|
||||
import net.hostsharing.hsadminng.mapper.StrictMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class HsCredentialsContextsController implements LoginContextsApi {
|
||||
|
||||
@Autowired
|
||||
private Context context;
|
||||
|
||||
@Autowired
|
||||
private StrictMapper mapper;
|
||||
|
||||
@Autowired
|
||||
private HsCredentialsContextRbacRepository contextRepo;
|
||||
|
||||
@Override
|
||||
public ResponseEntity<List<LoginContextResource>> getListOfLoginContexts(final String assumedRoles) {
|
||||
context.assumeRoles(assumedRoles);
|
||||
|
||||
final var loginContexts = contextRepo.findAll();
|
||||
final var result = mapper.mapList(loginContexts, LoginContextResource.class);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
@@ -0,0 +1,96 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import net.hostsharing.hsadminng.context.Context;
|
||||
import net.hostsharing.hsadminng.credentials.generated.api.v1.api.LoginCredentialsApi;
|
||||
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsInsertResource;
|
||||
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource;
|
||||
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsResource;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRbacRepository;
|
||||
import net.hostsharing.hsadminng.mapper.StrictMapper;
|
||||
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class HsCredentialsController implements LoginCredentialsApi {
|
||||
|
||||
@Autowired
|
||||
private Context context;
|
||||
|
||||
@Autowired
|
||||
private EntityManagerWrapper em;
|
||||
|
||||
@Autowired
|
||||
private StrictMapper mapper;
|
||||
|
||||
@Autowired
|
||||
private HsOfficePersonRbacRepository personRepo;
|
||||
|
||||
@Autowired
|
||||
private HsCredentialsRepository loginCredentialsRepo;
|
||||
|
||||
@Override
|
||||
public ResponseEntity<LoginCredentialsResource> getSingleLoginCredentialsByUuid(
|
||||
final String assumedRoles,
|
||||
final UUID loginCredentialsUuid) {
|
||||
context.assumeRoles(assumedRoles);
|
||||
|
||||
final var credentials = loginCredentialsRepo.findByUuid(loginCredentialsUuid);
|
||||
final var result = mapper.map(credentials, LoginCredentialsResource.class);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<List<LoginCredentialsResource>> getListOfLoginCredentialsByPersonUuid(
|
||||
final String assumedRoles,
|
||||
final UUID personUuid
|
||||
) {
|
||||
context.assumeRoles(assumedRoles);
|
||||
|
||||
final var person = personRepo.findByUuid(personUuid).orElseThrow(); // FIXME: use proper exception
|
||||
final var credentials = loginCredentialsRepo.findByPerson(person);
|
||||
final var result = mapper.mapList(credentials, LoginCredentialsResource.class);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<LoginCredentialsResource> postNewLoginCredentials(
|
||||
final String assumedRoles,
|
||||
final LoginCredentialsInsertResource body
|
||||
) {
|
||||
context.assumeRoles(assumedRoles);
|
||||
|
||||
final var newLoginCredentialsEntity = mapper.map(body, HsCredentialsEntity.class);
|
||||
final var savedLoginCredentialsEntity = loginCredentialsRepo.save(newLoginCredentialsEntity);
|
||||
final var newLoginCredentialsResource = mapper.map(savedLoginCredentialsEntity, LoginCredentialsResource.class);
|
||||
return ResponseEntity.ok(newLoginCredentialsResource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> deleteLoginCredentialsByUuid(final String assumedRoles, final UUID loginCredentialsUuid) {
|
||||
context.assumeRoles(assumedRoles);
|
||||
final var loginCredentialsEntity = em.getReference(HsCredentialsEntity.class, loginCredentialsUuid);
|
||||
em.remove(loginCredentialsEntity);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<LoginCredentialsResource> patchLoginCredentials(
|
||||
final String assumedRoles,
|
||||
final UUID loginCredentialsUuid,
|
||||
final LoginCredentialsPatchResource body
|
||||
) {
|
||||
context.assumeRoles(assumedRoles);
|
||||
|
||||
final var current = loginCredentialsRepo.findByUuid(loginCredentialsUuid).orElseThrow();
|
||||
|
||||
new HsCredentialsEntityPatcher(em, current).apply(body);
|
||||
|
||||
final var saved = loginCredentialsRepo.save(current);
|
||||
final var mapped = mapper.map(saved, LoginCredentialsResource.class);
|
||||
return ResponseEntity.ok(mapped);
|
||||
}
|
||||
}
|
@@ -0,0 +1,100 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
|
||||
import net.hostsharing.hsadminng.persistence.BaseEntity; // Assuming BaseEntity exists
|
||||
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
|
||||
import net.hostsharing.hsadminng.repr.Stringify;
|
||||
import net.hostsharing.hsadminng.repr.Stringifyable;
|
||||
// import net.hostsharing.hsadminng.rbac.RbacSubjectEntity; // Assuming RbacSubjectEntity exists for the FK relationship
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static jakarta.persistence.CascadeType.MERGE;
|
||||
import static jakarta.persistence.CascadeType.REFRESH;
|
||||
import static net.hostsharing.hsadminng.repr.Stringify.stringify;
|
||||
|
||||
@Entity
|
||||
@Table(schema = "hs_credentials", name = "credentials")
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class HsCredentialsEntity implements BaseEntity<HsCredentialsEntity>, Stringifyable {
|
||||
|
||||
protected static Stringify<HsCredentialsEntity> stringify = stringify(HsCredentialsEntity.class, "loginCredentials")
|
||||
.withProp(HsCredentialsEntity::isActive)
|
||||
.withProp(HsCredentialsEntity::getEmailAddress)
|
||||
.withProp(HsCredentialsEntity::getTwoFactorAuth)
|
||||
.withProp(HsCredentialsEntity::getPhonePassword)
|
||||
.withProp(HsCredentialsEntity::getSmsNumber)
|
||||
.quotedValues(false);
|
||||
|
||||
@Id
|
||||
private UUID uuid;
|
||||
|
||||
@MapsId
|
||||
@OneToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
|
||||
private RbacSubjectEntity subject;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "person_uuid", nullable = false, updatable = false, referencedColumnName = "uuid")
|
||||
private HsOfficePersonRealEntity person;
|
||||
|
||||
@Version
|
||||
private int version;
|
||||
|
||||
@Column
|
||||
private boolean active;
|
||||
|
||||
@Column
|
||||
private Integer globalUid;
|
||||
|
||||
@Column
|
||||
private Integer globalGid;
|
||||
|
||||
@Column
|
||||
private String onboardingToken;
|
||||
|
||||
@Column
|
||||
private String twoFactorAuth;
|
||||
|
||||
@Column
|
||||
private String phonePassword;
|
||||
|
||||
@Column
|
||||
private String emailAddress;
|
||||
|
||||
@Column
|
||||
private String smsNumber;
|
||||
|
||||
@OneToMany(fetch = FetchType.LAZY, cascade = { MERGE, REFRESH }, orphanRemoval = true)
|
||||
@JoinTable(
|
||||
name = "context_mapping", schema = "hs_credentials",
|
||||
joinColumns = @JoinColumn(name = "credentials_uuid", referencedColumnName = "uuid"),
|
||||
inverseJoinColumns = @JoinColumn(name = "context_uuid", referencedColumnName = "uuid")
|
||||
)
|
||||
private Set<HsCredentialsContextRealEntity> loginContexts;
|
||||
|
||||
public Set<HsCredentialsContextRealEntity> getLoginContexts() {
|
||||
if ( loginContexts == null ) {
|
||||
loginContexts = new HashSet<>();
|
||||
}
|
||||
return loginContexts;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toShortString() {
|
||||
return active + ":" + emailAddress + ":" + globalUid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return stringify.apply(this);
|
||||
}
|
||||
}
|
@@ -0,0 +1,74 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource;
|
||||
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource;
|
||||
import net.hostsharing.hsadminng.mapper.EntityPatcher;
|
||||
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<LoginCredentialsPatchResource> {
|
||||
|
||||
private final EntityManager em;
|
||||
private final HsCredentialsEntity entity;
|
||||
|
||||
public HsCredentialsEntityPatcher(final EntityManager em, final HsCredentialsEntity entity) {
|
||||
this.em = em;
|
||||
this.entity = entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(final LoginCredentialsPatchResource resource) {
|
||||
if ( resource.getActive() != null ) {
|
||||
entity.setActive(resource.getActive());
|
||||
}
|
||||
OptionalFromJson.of(resource.getEmailAddress())
|
||||
.ifPresent(entity::setEmailAddress);
|
||||
OptionalFromJson.of(resource.getTwoFactorAuth())
|
||||
.ifPresent(entity::setTwoFactorAuth);
|
||||
OptionalFromJson.of(resource.getSmsNumber())
|
||||
.ifPresent(entity::setSmsNumber);
|
||||
OptionalFromJson.of(resource.getPhonePassword())
|
||||
.ifPresent(entity::setPhonePassword);
|
||||
if (resource.getContexts() != null) {
|
||||
syncLoginContextEntities(resource.getContexts(), entity.getLoginContexts());
|
||||
}
|
||||
}
|
||||
|
||||
public void syncLoginContextEntities(
|
||||
List<LoginContextResource> resources,
|
||||
Set<HsCredentialsContextRealEntity> entities
|
||||
) {
|
||||
final var resourceUuids = resources.stream()
|
||||
.map(LoginContextResource::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 ) {
|
||||
// FIXME: i18n
|
||||
throw new EntityNotFoundException(
|
||||
HsCredentialsContextRealEntity.class.getName() + " with uuid " + resource.getUuid() + " not found.");
|
||||
}
|
||||
if (!existingContextEntity.getType().equals(resource.getType().name()) &&
|
||||
!existingContextEntity.getQualifier().equals(resource.getQualifier())) {
|
||||
// FIXME: i18n
|
||||
throw new EntityNotFoundException("existing " + existingContextEntity + " does not match given resource " + resource);
|
||||
}
|
||||
entities.add(existingContextEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import io.micrometer.core.annotation.Timed;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePerson;
|
||||
import org.springframework.data.repository.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface HsCredentialsRepository extends Repository<HsCredentialsEntity, UUID> {
|
||||
|
||||
@Timed("app.login.credentials.repo.findByUuid")
|
||||
Optional<HsCredentialsEntity> findByUuid(final UUID uuid);
|
||||
|
||||
@Timed("app.login.credentials.repo.findByPerson")
|
||||
List<HsCredentialsEntity> findByPerson(final HsOfficePerson personUuid);
|
||||
|
||||
@Timed("app.login.credentials.repo.save")
|
||||
HsCredentialsEntity save(final HsCredentialsEntity entity);
|
||||
}
|
@@ -4,6 +4,7 @@ package net.hostsharing.hsadminng.rbac.generator;
|
||||
import static java.util.stream.Collectors.joining;
|
||||
import static net.hostsharing.hsadminng.rbac.generator.StringWriter.indented;
|
||||
import static net.hostsharing.hsadminng.rbac.generator.StringWriter.with;
|
||||
import static net.hostsharing.hsadminng.rbac.generator.StringWriter.withQuoted;
|
||||
|
||||
public class RbacRestrictedViewGenerator {
|
||||
private final RbacSpec rbacDef;
|
||||
@@ -22,20 +23,21 @@ public class RbacRestrictedViewGenerator {
|
||||
--changeset RbacRestrictedViewGenerator:${liquibaseTagPrefix}-rbac-RESTRICTED-VIEW runOnChange:true validCheckSum:ANY endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
call rbac.generateRbacRestrictedView('${rawTableName}',
|
||||
$orderBy$
|
||||
${orderBy}
|
||||
$orderBy$,
|
||||
$updates$
|
||||
${updates}
|
||||
$updates$);
|
||||
${orderBy},
|
||||
${updates}
|
||||
);
|
||||
--//
|
||||
|
||||
""",
|
||||
with("liquibaseTagPrefix", liquibaseTagPrefix),
|
||||
with("orderBy", indented(2, rbacDef.getOrderBySqlExpression().sql)),
|
||||
with("updates", indented(2, rbacDef.getUpdatableColumns().stream()
|
||||
.map(c -> c + " = new." + c)
|
||||
.collect(joining(",\n")))),
|
||||
withQuoted("orderBy", indented(2, rbacDef.getOrderBySqlExpression().sql)),
|
||||
withQuoted("updates",
|
||||
rbacDef.getUpdatableColumns().isEmpty()
|
||||
? null
|
||||
: indented(2, rbacDef.getUpdatableColumns().stream()
|
||||
.map(c -> c + " = new." + c)
|
||||
.collect(joining(",\n")))
|
||||
),
|
||||
with("rawTableName", rawTableName));
|
||||
}
|
||||
}
|
||||
|
@@ -38,6 +38,8 @@ public class RbacSpec {
|
||||
public static final String GLOBAL = "rbac.global";
|
||||
public static final String OUTPUT_BASEDIR = "src/main/resources/db/changelog";
|
||||
|
||||
public static Consumer<RbacSpec.RbacRoleDefinition> WITHOUT_IMPLICIT_GRANTS = with -> {};
|
||||
|
||||
private final EntityAlias rootEntityAlias;
|
||||
|
||||
private final Set<RbacSubjectReference> userDefs = new LinkedHashSet<>();
|
||||
@@ -109,12 +111,23 @@ public class RbacSpec {
|
||||
* @return
|
||||
* the `this` instance itself to allow chained calls.
|
||||
*/
|
||||
public RbacSpec withUpdatableColumns(final String... columnNames) {
|
||||
public RbacSpec withUpdatableColumns(final String columnName, final String... columnNames) {
|
||||
Collections.addAll(updatableColumns, columnName);
|
||||
Collections.addAll(updatableColumns, columnNames);
|
||||
verifyVersionColumnExists();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies, thta no columns are updatable at all.
|
||||
*
|
||||
* @return this
|
||||
*/
|
||||
public RbacSpec withoutUpdatableColumns() {
|
||||
updatableColumns.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Specifies the SQL query which creates the identity view for this entity.
|
||||
*
|
||||
* <p>An identity view is a view which maps an objectUuid to an idName.
|
||||
@@ -188,7 +201,6 @@ public class RbacSpec {
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Specifies that the given role (OWNER, ADMIN, ...) is to be created for new/updated roles in this table,
|
||||
* which is becomes sub-role of the previously created role.
|
||||
|
@@ -99,6 +99,11 @@ class RolesGrantsAndPermissionsGenerator {
|
||||
.distinct()
|
||||
.map(columnName -> "NEW." + columnName + " is distinct from OLD." + columnName)
|
||||
.collect(joining( "\n or "));
|
||||
if (updateConditions.isBlank()) {
|
||||
// TODO_impl: RBAC rules for _rv do not yet work properly - check if this comment appears in generated output!
|
||||
plPgSql.writeLn("-- no updatable columns found, skipping update trigger");
|
||||
return;
|
||||
}
|
||||
plPgSql.writeLn("""
|
||||
/*
|
||||
Called from the AFTER UPDATE TRIGGER to re-wire the grants.
|
||||
|
@@ -11,7 +11,11 @@ public class StringWriter {
|
||||
private int indentLevel = 0;
|
||||
|
||||
static VarDef with(final String var, final String name) {
|
||||
return new VarDef(var, name);
|
||||
return new VarDef(var, name, false);
|
||||
}
|
||||
|
||||
static VarDef withQuoted(final String var, final String name) {
|
||||
return new VarDef(var, name, true);
|
||||
}
|
||||
|
||||
void writeLn(final String text) {
|
||||
@@ -97,7 +101,7 @@ public class StringWriter {
|
||||
return indented(indentLevel, text);
|
||||
}
|
||||
|
||||
record VarDef(String name, String value){}
|
||||
record VarDef(String name, String value, boolean quoted) {}
|
||||
|
||||
private static final class VarReplacer {
|
||||
|
||||
@@ -111,13 +115,22 @@ public class StringWriter {
|
||||
String apply(final String textToAppend) {
|
||||
text = textToAppend;
|
||||
stream(varDefs).forEach(varDef -> {
|
||||
// TODO.impl: I actually want a case-independent search+replace but ...
|
||||
// for which the substitution String can contain sequences of "${...}" to be replaced by further varDefs.
|
||||
text = text.replace("${" + varDef.name() + "}", varDef.value());
|
||||
text = text.replace("${" + varDef.name().toUpperCase() + "}", varDef.value());
|
||||
text = text.replace("${" + varDef.name().toLowerCase() + "}", varDef.value());
|
||||
replacePlaceholder(varDef.name(),
|
||||
varDef.quoted()
|
||||
? varDef.value() != null
|
||||
? "$" + varDef.name() + "$\n" + varDef.value() + "\n$" + varDef.name() + "$"
|
||||
: "null"
|
||||
: varDef.value());
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
private void replacePlaceholder(final String name, final String value) {
|
||||
// TODO.impl: I actually want a case-independent search+replace but ...
|
||||
// for which the substitution String can contain sequences of "${...}" to be replaced by further varDefs.
|
||||
text = text.replace("${" + name+ "}", value);
|
||||
text = text.replace("${" + name.toUpperCase() + "}", value);
|
||||
text = text.replace("${" + name.toLowerCase() + "}", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,17 @@
|
||||
openapi-processor-mapping: v2
|
||||
|
||||
options:
|
||||
package-name: net.hostsharing.hsadminng.credentials.generated.api.v1
|
||||
model-name-suffix: Resource
|
||||
bean-validation: true
|
||||
|
||||
map:
|
||||
result: org.springframework.http.ResponseEntity
|
||||
|
||||
types:
|
||||
- type: array => java.util.List
|
||||
- type: string:uuid => java.util.UUID
|
||||
|
||||
paths:
|
||||
/api/hs/credentials/credentials/{credentialsUuid}:
|
||||
null: org.openapitools.jackson.nullable.JsonNullable
|
23
src/main/resources/api-definition/credentials/api-paths.yaml
Normal file
23
src/main/resources/api-definition/credentials/api-paths.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Hostsharing hsadmin-ng API
|
||||
version: v0
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
description: Local development default URL.
|
||||
|
||||
paths:
|
||||
|
||||
# Contexts
|
||||
|
||||
/api/hs/credentials/contexts:
|
||||
$ref: "contexts.yaml"
|
||||
|
||||
|
||||
# Credentials
|
||||
|
||||
/api/hs/credentials/credentials:
|
||||
$ref: "api-paths.yaml"
|
||||
|
||||
/api/hs/credentials/credentials/{credentialsUuid}:
|
||||
$ref: "credentials-with-uuid.yaml"
|
1
src/main/resources/api-definition/credentials/auth.yaml
Symbolic link
1
src/main/resources/api-definition/credentials/auth.yaml
Symbolic link
@@ -0,0 +1 @@
|
||||
../auth.yaml
|
@@ -0,0 +1,21 @@
|
||||
|
||||
components:
|
||||
|
||||
schemas:
|
||||
|
||||
Context:
|
||||
type: object
|
||||
properties:
|
||||
uuid:
|
||||
type: string
|
||||
format: uuid
|
||||
type:
|
||||
type: string
|
||||
maxLength: 16
|
||||
qualifier:
|
||||
type: string
|
||||
maxLength: 80
|
||||
required:
|
||||
- uuid
|
||||
- type
|
||||
- qualifier
|
21
src/main/resources/api-definition/credentials/contexts.yaml
Normal file
21
src/main/resources/api-definition/credentials/contexts.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
get:
|
||||
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.
|
||||
tags:
|
||||
- -contexts
|
||||
operationId: getListOfContexts
|
||||
parameters:
|
||||
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: 'context-schemas.yaml#/components/schemas/Context'
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
@@ -0,0 +1,91 @@
|
||||
|
||||
components:
|
||||
|
||||
schemas:
|
||||
|
||||
Credentials:
|
||||
type: object
|
||||
properties:
|
||||
uuid:
|
||||
type: string
|
||||
format: uuid
|
||||
twoFactorAuth:
|
||||
type: string
|
||||
telephonePassword:
|
||||
type: string
|
||||
emailAddress:
|
||||
type: string
|
||||
smsNumber:
|
||||
type: string
|
||||
active:
|
||||
type: boolean
|
||||
globalUid:
|
||||
type: number
|
||||
globalGid:
|
||||
type: number
|
||||
onboardingToken:
|
||||
type: string
|
||||
contexts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '-context-schemas.yaml#/components/schemas/Context'
|
||||
required:
|
||||
- uuid
|
||||
- active
|
||||
- contexts
|
||||
additionalProperties: false
|
||||
|
||||
CredentialsPatch:
|
||||
type: object
|
||||
properties:
|
||||
twoFactorAuth:
|
||||
type: string
|
||||
nullable: true
|
||||
phonePassword:
|
||||
type: string
|
||||
nullable: true
|
||||
emailAddress:
|
||||
type: string
|
||||
nullable: true
|
||||
smsNumber:
|
||||
type: string
|
||||
nullable: true
|
||||
active:
|
||||
type: boolean
|
||||
contexts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '-context-schemas.yaml#/components/schemas/Context'
|
||||
additionalProperties: false
|
||||
|
||||
CredentialsInsert:
|
||||
type: object
|
||||
properties:
|
||||
uuid:
|
||||
type: string
|
||||
format: uuid
|
||||
twoFactorAuth:
|
||||
type: string
|
||||
telephonePassword:
|
||||
type: string
|
||||
emailAddress:
|
||||
type: string
|
||||
smsNumber:
|
||||
type: string
|
||||
active:
|
||||
type: boolean
|
||||
globalUid:
|
||||
type: number
|
||||
globalGid:
|
||||
type: number
|
||||
onboardingToken:
|
||||
type: string
|
||||
contexts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '-context-schemas.yaml#/components/schemas/Context'
|
||||
required:
|
||||
- uuid
|
||||
- active
|
||||
additionalProperties: false
|
||||
|
@@ -0,0 +1,80 @@
|
||||
get:
|
||||
tags:
|
||||
- -credentials
|
||||
description: 'Fetch a single credentials its uuid, if visible for the current subject.'
|
||||
operationId: getSingleCredentialsByUuid
|
||||
parameters:
|
||||
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
|
||||
- name: CredentialsUuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: UUID of the credentials to fetch.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
$ref: 'credentials-schemas.yaml#/components/schemas/Credentials'
|
||||
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
||||
|
||||
patch:
|
||||
tags:
|
||||
- -credentials
|
||||
description: 'Updates a single credentials identified by its uuid, if permitted for the current subject.'
|
||||
operationId: patchCredentials
|
||||
parameters:
|
||||
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
|
||||
- name: CredentialsUuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
$ref: 'credentials-schemas.yaml#/components/schemas/CredentialsPatch'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
$ref: 'credentials-schemas.yaml#/components/schemas/Credentials'
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
||||
|
||||
delete:
|
||||
tags:
|
||||
- -credentials
|
||||
description: 'Delete a single credentials identified by its uuid, if permitted for the current subject.'
|
||||
operationId: deleteCredentialsByUuid
|
||||
parameters:
|
||||
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
|
||||
- name: CredentialsUuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: UUID of the credentials to delete.
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
||||
"404":
|
||||
$ref: 'error-responses.yaml#/components/responses/NotFound'
|
@@ -0,0 +1,56 @@
|
||||
get:
|
||||
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.
|
||||
tags:
|
||||
- -credentials
|
||||
operationId: getListOfCredentialsByPersonUuid
|
||||
parameters:
|
||||
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
|
||||
- name: personUuid
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: The UUID of the person, whose credentials are to be fetched.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: 'credentials-schemas.yaml#/components/schemas/Credentials'
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
||||
|
||||
post:
|
||||
summary: Adds a new credentials.
|
||||
tags:
|
||||
- -credentials
|
||||
operationId: postNewCredentials
|
||||
parameters:
|
||||
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
|
||||
requestBody:
|
||||
description: A JSON object describing the new credential.
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: 'credentials-schemas.yaml#/components/schemas/CredentialsInsert'
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
content:
|
||||
'application/json':
|
||||
schema:
|
||||
$ref: 'credentials-schemas.yaml#/components/schemas/Credentials'
|
||||
"401":
|
||||
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
|
||||
"403":
|
||||
$ref: 'error-responses.yaml#/components/responses/Forbidden'
|
||||
"409":
|
||||
$ref: 'error-responses.yaml#/components/responses/Conflict'
|
@@ -0,0 +1,40 @@
|
||||
components:
|
||||
|
||||
responses:
|
||||
NotFound:
|
||||
description: The specified resource was not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
Unauthorized:
|
||||
description: The current subject is unknown or not authorized.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
Forbidden:
|
||||
description: The current subject or none of the assumed or roles is granted access to the resource.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
Conflict:
|
||||
description: The request could not be completed due to a conflict with the current state of the target resource.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
|
||||
schemas:
|
||||
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- code
|
||||
- message
|
@@ -16,6 +16,7 @@ components:
|
||||
- OPERATIONS_ALERT
|
||||
- SUBSCRIBER
|
||||
|
||||
|
||||
HsOfficeRelation:
|
||||
type: object
|
||||
properties:
|
||||
|
@@ -91,13 +91,16 @@ end; $$;
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs-global-historization-tx-create-historicization endDelimiter:--//
|
||||
--changeset michael.hoennig:hs-global-historization-tx-create-historicization runOnChange:true validCheckSum:ANY endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
create or replace procedure base.tx_create_historicization(baseTable varchar)
|
||||
create or replace procedure base.tx_create_historicization(
|
||||
basetable varchar -- format 'schemaname.tablename'
|
||||
)
|
||||
language plpgsql as $$
|
||||
declare
|
||||
baseSchemaName varchar;
|
||||
baseTableName varchar;
|
||||
createHistTableSql varchar;
|
||||
createTriggerSQL varchar;
|
||||
viewName varchar;
|
||||
@@ -106,14 +109,19 @@ declare
|
||||
baseCols varchar;
|
||||
begin
|
||||
|
||||
-- determine schema and pure table name
|
||||
SELECT split_part(basetable, '.', 1),
|
||||
split_part(basetable, '.', 2)
|
||||
INTO baseSchemaName, baseTableName;
|
||||
|
||||
-- create the history table
|
||||
createHistTableSql = '' ||
|
||||
'CREATE TABLE ' || baseTable || '_ex (' ||
|
||||
'CREATE TABLE ' || basetable || '_ex (' ||
|
||||
' version_id serial PRIMARY KEY,' ||
|
||||
' txid xid8 NOT NULL REFERENCES base.tx_context(txid),' ||
|
||||
' trigger_op base.tx_operation NOT NULL,' ||
|
||||
' alive boolean not null,' ||
|
||||
' LIKE ' || baseTable ||
|
||||
' LIKE ' || basetable ||
|
||||
' EXCLUDING CONSTRAINTS' ||
|
||||
' EXCLUDING STATISTICS' ||
|
||||
')';
|
||||
@@ -121,12 +129,12 @@ begin
|
||||
execute createHistTableSql;
|
||||
|
||||
-- create the historical view
|
||||
viewName = baseTable || '_hv';
|
||||
exVersionsTable = baseTable || '_ex';
|
||||
viewName = basetable || '_hv';
|
||||
exVersionsTable = basetable || '_ex';
|
||||
baseCols = (select string_agg(quote_ident(column_name), ', ')
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = baseTable);
|
||||
where table_schema = baseSchemaName
|
||||
and table_name = baseTableName);
|
||||
|
||||
createViewSQL = format(
|
||||
'CREATE OR REPLACE VIEW %1$s AS' ||
|
||||
@@ -152,7 +160,7 @@ begin
|
||||
|
||||
-- "-9-" to put the trigger execution after any alphabetically lesser tx-triggers
|
||||
createTriggerSQL = 'CREATE TRIGGER tx_9_historicize_tg' ||
|
||||
' AFTER INSERT OR DELETE OR UPDATE ON ' || baseTable ||
|
||||
' AFTER INSERT OR DELETE OR UPDATE ON ' || basetable ||
|
||||
' FOR EACH ROW EXECUTE PROCEDURE base.tx_historicize_tf()';
|
||||
execute createTriggerSQL;
|
||||
|
||||
|
@@ -0,0 +1,25 @@
|
||||
--liquibase formatted sql
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:base-array-functions-WITHOUT-NULL-VALUES endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION base.without_null_values(arr anyarray)
|
||||
RETURNS anyarray
|
||||
AS $$
|
||||
SELECT array_agg(e) FROM unnest(arr) AS e WHERE e IS NOT NULL
|
||||
$$ LANGUAGE sql;
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:base-array-functions-ADD-IF-NOT-NULLCREATE OR REPLACE FUNCTION add_if_not_null(anyarray, anyelement)
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION base.add_if_not_null(arr anyarray, val anyelement)
|
||||
RETURNS anyarray
|
||||
AS $$
|
||||
SELECT CASE WHEN val IS NULL THEN arr ELSE arr || val END
|
||||
$$ LANGUAGE sql;
|
||||
|
||||
|
@@ -536,7 +536,7 @@ $$;
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:rbac-base-GRANTS endDelimiter:--//
|
||||
--changeset michael.hoennig:rbac-base-GRANTS endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
/*
|
||||
Table to store grants / role- or permission assignments to subjects or roles.
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:rbac-context-DETERMINE endDelimiter:--//
|
||||
--changeset michael.hoennig:rbac-context-DETERMINE runOnChange:true validCheckSum:ANY endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
create or replace function rbac.determineCurrentSubjectUuid(currentSubject varchar)
|
||||
|
@@ -148,12 +148,12 @@ commit;
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:rbac-global-GUEST-ROLE endDelimiter:--//
|
||||
--changeset michael.hoennig:rbac-global-GUEST-ROLE runOnChange:true validCheckSum:ANY endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
/*
|
||||
A rbac.Global guest role.
|
||||
*/
|
||||
create or replace function rbac.globalglobalGuest(assumed boolean = true)
|
||||
create or replace function rbac.global_GUEST(assumed boolean = true)
|
||||
returns rbac.RoleDescriptor
|
||||
returns null on null input
|
||||
stable -- leakproof
|
||||
@@ -161,10 +161,17 @@ create or replace function rbac.globalglobalGuest(assumed boolean = true)
|
||||
select 'rbac.global', (select uuid from rbac.object where objectTable = 'rbac.global'), 'GUEST'::rbac.RoleType, assumed;
|
||||
$$;
|
||||
|
||||
begin transaction;
|
||||
call base.defineContext('creating role:rbac.global#global:guest', null, null, null);
|
||||
select rbac.createRole(rbac.globalglobalGuest());
|
||||
commit;
|
||||
do language plpgsql $$
|
||||
begin
|
||||
call base.defineContext('creating role:rbac.global#global:guest', null, null, null);
|
||||
begin
|
||||
perform rbac.createRole(rbac.global_GUEST());
|
||||
exception
|
||||
when unique_violation then
|
||||
null; -- ignore if it already exists from prev execution of this changeset
|
||||
end;
|
||||
end;
|
||||
$$;
|
||||
--//
|
||||
|
||||
|
||||
|
@@ -0,0 +1,19 @@
|
||||
--liquibase formatted sql
|
||||
|
||||
-- In a separate file to avoid changed checksums in the existing changsets.
|
||||
-- I presume it's a bug in Liquibase that other changeset checksums are changed by new changesets in the same file
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs-office-person-TEST-DATA-GENERATION-FOR-CREDENTIALS context:!without-test-data endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
do language plpgsql $$
|
||||
begin
|
||||
call hs_office.person_create_test_data('NP', null,'Hostmaster', 'Alex');
|
||||
call hs_office.person_create_test_data('NP', null, 'Hostmaster', 'Fran');
|
||||
call hs_office.person_create_test_data('NP', null, 'User', 'Drew');
|
||||
call hs_office.person_create_test_data('NP', null, 'User', 'Test');
|
||||
end;
|
||||
$$;
|
||||
--//
|
||||
|
@@ -0,0 +1,8 @@
|
||||
--liquibase formatted sql
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs-credentials-SCHEMA endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
CREATE SCHEMA hs_credentials;
|
||||
--//
|
@@ -0,0 +1,91 @@
|
||||
--liquibase formatted sql
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs-credentials-CREDENTIALS-TABLE endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
create table hs_credentials.credentials
|
||||
(
|
||||
uuid uuid PRIMARY KEY references rbac.subject (uuid) initially deferred,
|
||||
version int not null default 0,
|
||||
|
||||
person_uuid uuid not null references hs_office.person(uuid),
|
||||
|
||||
active bool,
|
||||
global_uid int unique, -- w/o
|
||||
global_gid int unique, -- w/o
|
||||
onboarding_token text, -- w/o
|
||||
|
||||
two_factor_auth text,
|
||||
phone_password text,
|
||||
email_address text,
|
||||
sms_number text
|
||||
);
|
||||
--//
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs-credentials-context-CONTEXT-TABLE endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
create table hs_credentials.context
|
||||
(
|
||||
uuid uuid PRIMARY KEY,
|
||||
version int not null default 0,
|
||||
|
||||
type varchar(16),
|
||||
qualifier varchar(80),
|
||||
|
||||
unique (type, qualifier)
|
||||
);
|
||||
--//
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs-credentials-CONTEXT-IMMUTABLE-TRIGGER endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION hs_credentials.prevent_context_update()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'Updates to hs_credentials.context are not allowed.';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger to enforce immutability
|
||||
CREATE TRIGGER context_immutable_trigger
|
||||
BEFORE UPDATE ON hs_credentials.context
|
||||
FOR EACH ROW EXECUTE FUNCTION hs_credentials.prevent_context_update();
|
||||
--//
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs_credentials-CONTEXT-MAPPING endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
create table hs_credentials.context_mapping
|
||||
(
|
||||
uuid uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
credentials_uuid uuid references hs_credentials.credentials(uuid) ON DELETE CASCADE,
|
||||
context_uuid uuid references hs_credentials.context(uuid) ON DELETE RESTRICT
|
||||
);
|
||||
--//
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs-hs_credentials-JOURNALS endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
call base.create_journal('hs_credentials.context_mapping');
|
||||
call base.create_journal('hs_credentials.context');
|
||||
call base.create_journal('hs_credentials.credentials');
|
||||
--//
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs_credentials-HISTORICIZATION endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
call base.tx_create_historicization('hs_credentials.context_mapping');
|
||||
call base.tx_create_historicization('hs_credentials.context');
|
||||
call base.tx_create_historicization('hs_credentials.credentials');
|
||||
--//
|
@@ -0,0 +1,41 @@
|
||||
### rbac credentialsContext
|
||||
|
||||
This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually.
|
||||
|
||||
```mermaid
|
||||
%%{init:{'flowchart':{'htmlLabels':false}}}%%
|
||||
flowchart TB
|
||||
|
||||
subgraph credentialsContext["`**credentialsContext**`"]
|
||||
direction TB
|
||||
style credentialsContext fill:#dd4901,stroke:#274d6e,stroke-width:8px
|
||||
|
||||
subgraph credentialsContext:roles[ ]
|
||||
style credentialsContext:roles fill:#dd4901,stroke:white
|
||||
|
||||
role:credentialsContext:OWNER[[credentialsContext:OWNER]]
|
||||
role:credentialsContext:ADMIN[[credentialsContext:ADMIN]]
|
||||
role:credentialsContext:REFERRER[[credentialsContext:REFERRER]]
|
||||
end
|
||||
|
||||
subgraph credentialsContext:permissions[ ]
|
||||
style credentialsContext:permissions fill:#dd4901,stroke:white
|
||||
|
||||
perm:credentialsContext:INSERT{{credentialsContext:INSERT}}
|
||||
perm:credentialsContext:UPDATE{{credentialsContext:UPDATE}}
|
||||
perm:credentialsContext:DELETE{{credentialsContext:DELETE}}
|
||||
perm:credentialsContext:SELECT{{credentialsContext:SELECT}}
|
||||
end
|
||||
end
|
||||
|
||||
%% granting roles to roles
|
||||
role:credentialsContext:OWNER ==> role:credentialsContext:ADMIN
|
||||
role:credentialsContext:ADMIN ==> role:credentialsContext:REFERRER
|
||||
|
||||
%% granting permissions to roles
|
||||
role:rbac.global:ADMIN ==> perm:credentialsContext:INSERT
|
||||
role:rbac.global:ADMIN ==> perm:credentialsContext:UPDATE
|
||||
role:rbac.global:ADMIN ==> perm:credentialsContext:DELETE
|
||||
role:rbac.global:REFERRER ==> perm:credentialsContext:SELECT
|
||||
|
||||
```
|
@@ -0,0 +1,68 @@
|
||||
--liquibase formatted sql
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
--changeset michael.hoennig:hs_credentials-credentials-TEST-DATA context:!without-test-data endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
do language plpgsql $$
|
||||
|
||||
declare
|
||||
superuserAlexSubjectUuid uuid;
|
||||
personAlexUuid uuid;
|
||||
superuserFranSubjectUuid uuid;
|
||||
personFranUuid uuid;
|
||||
|
||||
context_HSADMIN_prod hs_credentials.context;
|
||||
context_SSH_internal hs_credentials.context;
|
||||
context_MATRIX_internal hs_credentials.context;
|
||||
|
||||
begin
|
||||
call base.defineContext('creating booking-project test-data', null, 'superuser-alex@hostsharing.net', 'rbac.global#global:ADMIN');
|
||||
|
||||
superuserAlexSubjectUuid = (SELECT uuid FROM rbac.subject WHERE name='superuser-alex@hostsharing.net');
|
||||
personAlexUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Alex');
|
||||
superuserFranSubjectUuid = (SELECT uuid FROM rbac.subject WHERE name='superuser-fran@hostsharing.net');
|
||||
personFranUuid = (SELECT uuid FROM hs_office.person WHERE givenName='Fran');
|
||||
|
||||
-- Add test contexts
|
||||
INSERT INTO hs_credentials.context (uuid, type, qualifier) VALUES
|
||||
('11111111-1111-1111-1111-111111111111', 'HSADMIN', 'prod')
|
||||
RETURNING * INTO context_HSADMIN_prod;
|
||||
INSERT INTO hs_credentials.context (uuid, type, qualifier) VALUES
|
||||
('22222222-2222-2222-2222-222222222222', 'SSH', 'internal')
|
||||
RETURNING * INTO context_SSH_internal;
|
||||
INSERT INTO hs_credentials.context (uuid, type, qualifier) VALUES
|
||||
('33333333-3333-3333-3333-333333333333', 'MATRIX', 'internal')
|
||||
RETURNING * INTO context_MATRIX_internal;
|
||||
|
||||
-- grant general access to public credential contexts
|
||||
-- TODO_impl: RBAC rules for _rv do not yet work properly
|
||||
-- call rbac.grantPermissiontoRole(
|
||||
-- rbac.createPermission(context_HSADMIN_prod.uuid, 'SELECT'),
|
||||
-- rbac.global_GUEST());
|
||||
-- call rbac.grantPermissiontoRole(
|
||||
-- rbac.createPermission(context_SSH_internal.uuid, 'SELECT'),
|
||||
-- rbac.global_ADMIN());
|
||||
-- call rbac.grantPermissionToRole(
|
||||
-- rbac.createPermission(context_MATRIX_internal.uuid, 'SELECT'),
|
||||
-- rbac.global_ADMIN());
|
||||
-- call rbac.grantRoleToRole(hs_credentials.context_REFERRER(context_SSH_internal), rbac.global_ADMIN());
|
||||
-- call rbac.grantRoleToRole(hs_credentials.context_REFERRER(context_MATRIX_internal), rbac.global_ADMIN());
|
||||
|
||||
-- Add test credentials (linking to assumed rbac.subject UUIDs)
|
||||
INSERT INTO hs_credentials.credentials (uuid, version, person_uuid, active, global_uid, global_gid, onboarding_token, two_factor_auth, phone_password, email_address, sms_number) VALUES
|
||||
( superuserAlexSubjectUuid, 0, personAlexUuid, true, 1001, 1001, 'token-abc', 'otp-secret-1', 'phone-pw-1', 'alex@example.com', '111-222-3333'),
|
||||
( superuserFranSubjectUuid, 0, personFranUuid, true, 1002, 1002, 'token-def', 'otp-secret-2', 'phone-pw-2', 'fran@example.com', '444-555-6666');
|
||||
|
||||
-- Map credentials to contexts
|
||||
INSERT INTO hs_credentials.context_mapping (credentials_uuid, context_uuid) VALUES
|
||||
(superuserAlexSubjectUuid, '11111111-1111-1111-1111-111111111111'), -- HSADMIN context
|
||||
(superuserFranSubjectUuid, '11111111-1111-1111-1111-111111111111'), -- HSADMIN context
|
||||
(superuserAlexSubjectUuid, '22222222-2222-2222-2222-222222222222'), -- SSH context
|
||||
(superuserFranSubjectUuid, '22222222-2222-2222-2222-222222222222'), -- SSH context
|
||||
(superuserAlexSubjectUuid, '33333333-3333-3333-3333-333333333333'), -- MATRIX context
|
||||
(superuserFranSubjectUuid, '33333333-3333-3333-3333-333333333333'); -- MATRIX context
|
||||
|
||||
end; $$;
|
||||
--//
|
@@ -4,5 +4,5 @@
|
||||
-- ============================================================================
|
||||
--changeset timotheus.pokorra:hs-integration-SCHEMA endDelimiter:--//
|
||||
-- ----------------------------------------------------------------------------
|
||||
CREATE SCHEMA hs_integration;
|
||||
CREATE SCHEMA IF NOT EXISTS hs_integration;
|
||||
--//
|
@@ -27,6 +27,8 @@ databaseChangeLog:
|
||||
file: db/changelog/0-base/020-audit-log.sql
|
||||
- include:
|
||||
file: db/changelog/0-base/030-historization.sql
|
||||
- include:
|
||||
file: db/changelog/0-base/040-array-functions.sql
|
||||
- include:
|
||||
file: db/changelog/0-base/090-log-slow-queries-extensions.sql
|
||||
|
||||
@@ -100,6 +102,9 @@ databaseChangeLog:
|
||||
- include:
|
||||
file: db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data.sql
|
||||
context: "!without-test-data"
|
||||
- include:
|
||||
file: db/changelog/5-hs-office/502-person/5028-hs-office-person-test-data-for-credentials.sql
|
||||
context: "!without-test-data"
|
||||
- include:
|
||||
file: db/changelog/5-hs-office/503-relation/5030-hs-office-relation.sql
|
||||
- include:
|
||||
@@ -212,19 +217,30 @@ databaseChangeLog:
|
||||
file: db/changelog/9-hs-global/9000-statistics.sql
|
||||
context: "!only-office"
|
||||
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/950-credentials/9500-hs-credentials-schema.sql
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/950-credentials/9510-hs-credentials.sql
|
||||
# TODO_impl: RBAC rules for _rv do not yet work properly
|
||||
# - include:
|
||||
# file: db/changelog/9-hs-global/950-credentials/9513-hs-credentials-rbac.sql
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/950-credentials/9519-hs-credentials-test-data.sql
|
||||
context: "!without-test-data"
|
||||
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/960-integrations/9600-hs-integration-schema.sql
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/960-integrations/9610-integration-kimai.sql
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/960-integrations/9620-integration-znuny.sql
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/960-integrations/9630-integration-mlmmj.sql
|
||||
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/9800-cleanup.sql
|
||||
context: "without-test-data"
|
||||
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/9100-hs-integration-schema.sql
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/9110-integration-kimai.sql
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/9120-integration-znuny.sql
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/9130-integration-mlmmj.sql
|
||||
|
||||
- include:
|
||||
file: db/changelog/9-hs-global/9999-liquibase-migration-test.sql
|
||||
context: liquibase-migration-test
|
||||
|
@@ -49,6 +49,7 @@ public class ArchitectureTest {
|
||||
"..test.pac",
|
||||
"..test.dom",
|
||||
"..context",
|
||||
"..credentials",
|
||||
"..hash",
|
||||
"..lambda",
|
||||
"..generated..",
|
||||
|
@@ -0,0 +1,20 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class HsCredentialsContextRbacEntityUnitTest {
|
||||
|
||||
@Test
|
||||
void toShortString() {
|
||||
final var entity = HsCredentialsContextRbacEntity.builder()
|
||||
.uuid(UUID.randomUUID())
|
||||
.type("SSH")
|
||||
.qualifier("prod")
|
||||
.build();
|
||||
assertEquals("loginContext(SSH:prod)", entity.toShortString());
|
||||
}
|
||||
}
|
@@ -0,0 +1,167 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import net.hostsharing.hsadminng.context.Context;
|
||||
import net.hostsharing.hsadminng.rbac.context.ContextBasedTest;
|
||||
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.postgresql.util.PSQLException;
|
||||
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.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.transaction.Transactional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@DataJpaTest
|
||||
@ActiveProfiles("test")
|
||||
@Tag("generalIntegrationTest")
|
||||
@Import({ Context.class, JpaAttempt.class })
|
||||
@Transactional
|
||||
class HsCredentialsContextRbacRepositoryIntegrationTest extends ContextBasedTest {
|
||||
|
||||
// existing UUIDs from test data (Liquibase changeset 310-login-credentials-test-data.sql)
|
||||
private static final UUID TEST_CONTEXT_HSADMIN_PROD_UUID = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
private static final UUID TEST_CONTEXT_MATRIX_INTERNAL_UUID = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||
|
||||
private static final String SUPERUSER_ALEX_SUBJECT_NAME = "superuser-alex@hostsharing.net";
|
||||
private static final String TEST_USER_SUBJECT_NAME = "selfregistered-test-user@hostsharing.org";
|
||||
|
||||
@MockitoBean
|
||||
HttpServletRequest request;
|
||||
|
||||
@Autowired
|
||||
private HsCredentialsContextRbacRepository loginContextRepository;
|
||||
|
||||
@Test
|
||||
void shouldFindAllByNormalUserUsingTestData() {
|
||||
context(TEST_USER_SUBJECT_NAME);
|
||||
|
||||
// when
|
||||
final var allContexts = loginContextRepository.findAll();
|
||||
|
||||
// then
|
||||
assertThat(allContexts)
|
||||
.isNotNull()
|
||||
.hasSizeGreaterThanOrEqualTo(1) // Expect at least the 1 public context from assumed test data
|
||||
.extracting(HsCredentialsContext::getUuid)
|
||||
.contains(TEST_CONTEXT_HSADMIN_PROD_UUID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFindAllByAdminUserUsingTestData() {
|
||||
context(SUPERUSER_ALEX_SUBJECT_NAME);
|
||||
|
||||
// when
|
||||
final var allContexts = loginContextRepository.findAll();
|
||||
|
||||
// then
|
||||
assertThat(allContexts)
|
||||
.isNotNull()
|
||||
.hasSizeGreaterThanOrEqualTo(3); // Expect at least the 1 public context from assumed test data
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFindByUuidUsingTestData() {
|
||||
context(TEST_USER_SUBJECT_NAME);
|
||||
|
||||
// when
|
||||
final var foundEntityOptional = loginContextRepository.findByUuid(TEST_CONTEXT_HSADMIN_PROD_UUID);
|
||||
|
||||
// then
|
||||
assertThat(foundEntityOptional).isPresent();
|
||||
assertThat(foundEntityOptional).map(Object::toString).contains("loginContext(HSADMIN:prod)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFindByTypeAndQualifierUsingTestData() {
|
||||
context(SUPERUSER_ALEX_SUBJECT_NAME);
|
||||
|
||||
// when
|
||||
final var foundEntityOptional = loginContextRepository.findByTypeAndQualifier("SSH", "internal");
|
||||
|
||||
// then
|
||||
assertThat(foundEntityOptional).isPresent();
|
||||
assertThat(foundEntityOptional).map(Object::toString).contains("loginContext(SSH:internal)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyOptionalWhenFindByTypeAndQualifierNotFound() {
|
||||
context(SUPERUSER_ALEX_SUBJECT_NAME);
|
||||
|
||||
// given
|
||||
final var nonExistentQualifier = "non-existent-qualifier";
|
||||
|
||||
// when
|
||||
final var foundEntityOptional = loginContextRepository.findByTypeAndQualifier(
|
||||
"HSADMIN", nonExistentQualifier);
|
||||
|
||||
// then
|
||||
assertThat(foundEntityOptional).isNotPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSaveNewLoginContext() {
|
||||
context(SUPERUSER_ALEX_SUBJECT_NAME);
|
||||
|
||||
// given
|
||||
final var newQualifier = "test@example.social";
|
||||
final var newType = "MASTODON";
|
||||
final var newContext = HsCredentialsContextRbacEntity.builder()
|
||||
.type(newType)
|
||||
.qualifier(newQualifier)
|
||||
.build();
|
||||
|
||||
// when
|
||||
final var savedEntity = loginContextRepository.save(newContext);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// then
|
||||
assertThat(savedEntity).isNotNull();
|
||||
final var generatedUuid = savedEntity.getUuid();
|
||||
assertThat(generatedUuid).isNotNull(); // Verify UUID was generated
|
||||
|
||||
// Fetch again using the generated UUID to confirm persistence
|
||||
context(SUPERUSER_ALEX_SUBJECT_NAME); // Re-set context if needed after clear
|
||||
final var foundEntityOptional = loginContextRepository.findByUuid(generatedUuid);
|
||||
assertThat(foundEntityOptional).isPresent();
|
||||
final var foundEntity = foundEntityOptional.get();
|
||||
assertThat(foundEntity.getUuid()).isEqualTo(generatedUuid);
|
||||
assertThat(foundEntity.getType()).isEqualTo(newType);
|
||||
assertThat(foundEntity.getQualifier()).isEqualTo(newQualifier);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreventUpdateOfExistingLoginContext() {
|
||||
context(SUPERUSER_ALEX_SUBJECT_NAME);
|
||||
|
||||
// given an existing entity from test data
|
||||
final var entityToUpdateOptional = loginContextRepository.findByUuid(TEST_CONTEXT_MATRIX_INTERNAL_UUID);
|
||||
assertThat(entityToUpdateOptional)
|
||||
.withFailMessage("Could not find existing LoginContext with UUID %s. Ensure test data exists.",
|
||||
TEST_CONTEXT_MATRIX_INTERNAL_UUID)
|
||||
.isPresent();
|
||||
final var entityToUpdate = entityToUpdateOptional.get();
|
||||
|
||||
// when
|
||||
entityToUpdate.setQualifier("updated");
|
||||
final var exception = catchThrowable( () -> {
|
||||
loginContextRepository.save(entityToUpdate);
|
||||
em.flush();
|
||||
});
|
||||
|
||||
// then
|
||||
assertThat(exception)
|
||||
.isInstanceOf(PersistenceException.class)
|
||||
.hasCauseInstanceOf(PSQLException.class)
|
||||
.hasMessageContaining("ERROR: Updates to hs_credentials.context are not allowed.");
|
||||
}
|
||||
}
|
@@ -0,0 +1,20 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class HsCredentialsContextRealEntityUnitTest {
|
||||
|
||||
@Test
|
||||
void toShortString() {
|
||||
final var entity = HsCredentialsContextRealEntity.builder()
|
||||
.uuid(UUID.randomUUID())
|
||||
.type("testType")
|
||||
.qualifier("testQualifier")
|
||||
.build();
|
||||
assertEquals("loginContext(testType:testQualifier)", entity.toShortString());
|
||||
}
|
||||
}
|
@@ -0,0 +1,177 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import net.hostsharing.hsadminng.context.Context;
|
||||
import net.hostsharing.hsadminng.rbac.context.ContextBasedTest;
|
||||
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.postgresql.util.PSQLException;
|
||||
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.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
import jakarta.persistence.PersistenceException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@DataJpaTest
|
||||
@ActiveProfiles("test")
|
||||
@Tag("generalIntegrationTest")
|
||||
@Import({ Context.class, JpaAttempt.class })
|
||||
class HsCredentialsContextRealRepositoryIntegrationTest extends ContextBasedTest {
|
||||
|
||||
// existing UUIDs from test data (Liquibase changeset 310-login-credentials-test-data.sql)
|
||||
private static final UUID TEST_CONTEXT_HSADMIN_PROD_UUID = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
private static final UUID TEST_CONTEXT_SSH_INTERNAL_UUID = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
private static final UUID TEST_CONTEXT_MATRIX_INTERNAL_UUID = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||
|
||||
private static final String SUPERUSER_ALEX_SUBJECT_NAME = "superuser-alex@hostsharing.net";
|
||||
private static final String TEST_USER_SUBJECT_NAME = "selfregistered-test-user@hostsharing.org";
|
||||
|
||||
@MockitoBean
|
||||
HttpServletRequest request;
|
||||
|
||||
@Autowired
|
||||
private HsCredentialsContextRealRepository loginContextRepository;
|
||||
|
||||
@Test
|
||||
public void historizationIsAvailable() {
|
||||
// given
|
||||
final String nativeQuerySql = "select * from hs_credentials.context_hv";
|
||||
|
||||
// when
|
||||
historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant()));
|
||||
final var query = em.createNativeQuery(nativeQuerySql);
|
||||
final var rowsBefore = query.getResultList();
|
||||
|
||||
// then
|
||||
assertThat(rowsBefore).as("hs_credentials.context_hv only contain no rows for a timestamp before test data creation").hasSize(0);
|
||||
|
||||
// and when
|
||||
historicalContext(Timestamp.from(ZonedDateTime.now().toInstant()));
|
||||
em.createNativeQuery(nativeQuerySql, Integer.class);
|
||||
final var rowsAfter = query.getResultList();
|
||||
|
||||
// then
|
||||
assertThat(rowsAfter).as("hs_credentials.context_hv should now contain the test-data rows for the current timestamp").hasSize(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFindAllUsingTestData() {
|
||||
context(TEST_USER_SUBJECT_NAME);
|
||||
|
||||
// when
|
||||
final var allContexts = loginContextRepository.findAll();
|
||||
|
||||
// then
|
||||
assertThat(allContexts)
|
||||
.isNotNull()
|
||||
.hasSizeGreaterThanOrEqualTo(3) // Expect at least the 3 from assumed test data
|
||||
.extracting(HsCredentialsContext::getUuid)
|
||||
.contains(TEST_CONTEXT_HSADMIN_PROD_UUID, TEST_CONTEXT_SSH_INTERNAL_UUID, TEST_CONTEXT_MATRIX_INTERNAL_UUID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFindByUuidUsingTestData() {
|
||||
context(TEST_USER_SUBJECT_NAME);
|
||||
|
||||
// when
|
||||
final var foundEntityOptional = loginContextRepository.findByUuid(TEST_CONTEXT_HSADMIN_PROD_UUID);
|
||||
|
||||
// then
|
||||
assertThat(foundEntityOptional).isPresent();
|
||||
assertThat(foundEntityOptional).map(Object::toString).contains("loginContext(HSADMIN:prod)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFindByTypeAndQualifierUsingTestData() {
|
||||
context(TEST_USER_SUBJECT_NAME);
|
||||
|
||||
// when
|
||||
final var foundEntityOptional = loginContextRepository.findByTypeAndQualifier("SSH", "internal");
|
||||
|
||||
// then
|
||||
assertThat(foundEntityOptional).isPresent();
|
||||
assertThat(foundEntityOptional).map(Object::toString).contains("loginContext(SSH:internal)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyOptionalWhenFindByTypeAndQualifierNotFound() {
|
||||
context(TEST_USER_SUBJECT_NAME);
|
||||
|
||||
// given
|
||||
final var nonExistentQualifier = "non-existent-qualifier";
|
||||
|
||||
// when
|
||||
final var foundEntityOptional = loginContextRepository.findByTypeAndQualifier(
|
||||
"HSADMIN", nonExistentQualifier);
|
||||
|
||||
// then
|
||||
assertThat(foundEntityOptional).isNotPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSaveNewLoginContext() {
|
||||
context(SUPERUSER_ALEX_SUBJECT_NAME);
|
||||
|
||||
// given
|
||||
final var newQualifier = "test@example.social";
|
||||
final var newType = "MASTODON";
|
||||
final var newContext = HsCredentialsContextRealEntity.builder()
|
||||
.type(newType)
|
||||
.qualifier(newQualifier)
|
||||
.build();
|
||||
|
||||
// when
|
||||
final var savedEntity = loginContextRepository.save(newContext);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// then
|
||||
assertThat(savedEntity).isNotNull();
|
||||
final var generatedUuid = savedEntity.getUuid();
|
||||
assertThat(generatedUuid).isNotNull(); // Verify UUID was generated
|
||||
|
||||
// Fetch again using the generated UUID to confirm persistence
|
||||
context(TEST_USER_SUBJECT_NAME); // Re-set context if needed after clear
|
||||
final var foundEntityOptional = loginContextRepository.findByUuid(generatedUuid);
|
||||
assertThat(foundEntityOptional).isPresent();
|
||||
final var foundEntity = foundEntityOptional.get();
|
||||
assertThat(foundEntity.getUuid()).isEqualTo(generatedUuid);
|
||||
assertThat(foundEntity.getType()).isEqualTo(newType);
|
||||
assertThat(foundEntity.getQualifier()).isEqualTo(newQualifier);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreventUpdateOfExistingLoginContext() {
|
||||
context(TEST_USER_SUBJECT_NAME);
|
||||
|
||||
// given an existing entity from test data
|
||||
final var entityToUpdateOptional = loginContextRepository.findByUuid(TEST_CONTEXT_MATRIX_INTERNAL_UUID);
|
||||
assertThat(entityToUpdateOptional)
|
||||
.withFailMessage("Could not find existing LoginContext with UUID %s. Ensure test data exists.",
|
||||
TEST_CONTEXT_MATRIX_INTERNAL_UUID)
|
||||
.isPresent();
|
||||
final var entityToUpdate = entityToUpdateOptional.get();
|
||||
|
||||
// when
|
||||
entityToUpdate.setQualifier("updated");
|
||||
final var exception = catchThrowable( () -> {
|
||||
loginContextRepository.save(entityToUpdate);
|
||||
em.flush();
|
||||
});
|
||||
|
||||
// then
|
||||
assertThat(exception)
|
||||
.isInstanceOf(PersistenceException.class)
|
||||
.hasCauseInstanceOf(PSQLException.class)
|
||||
.hasMessageContaining("ERROR: Updates to hs_credentials.context are not allowed.");
|
||||
}
|
||||
}
|
@@ -0,0 +1,122 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import static net.hostsharing.hsadminng.test.JsonMatcher.lenientlyEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
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;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import net.hostsharing.hsadminng.config.DisableSecurityConfig;
|
||||
import net.hostsharing.hsadminng.config.JsonObjectMapperConfiguration;
|
||||
import net.hostsharing.hsadminng.config.MessageTranslator;
|
||||
import net.hostsharing.hsadminng.context.Context;
|
||||
import net.hostsharing.hsadminng.mapper.StrictMapper;
|
||||
import net.hostsharing.hsadminng.persistence.EntityManagerWrapper;
|
||||
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;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
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.EntityManager;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import jakarta.persistence.SynchronizationType;
|
||||
|
||||
@WebMvcTest(HsCredentialsContextsController.class)
|
||||
@Import({ StrictMapper.class, JsonObjectMapperConfiguration.class, DisableSecurityConfig.class, MessageTranslator.class})
|
||||
@ActiveProfiles("test")
|
||||
class HsCredentialsContextsControllerRestTest {
|
||||
|
||||
@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
|
||||
HsCredentialsContextRbacRepository loginContextRbacRepo;
|
||||
|
||||
|
||||
@TestConfiguration
|
||||
public static class TestConfig {
|
||||
|
||||
@Bean
|
||||
public EntityManager entityManager() {
|
||||
return mock(EntityManager.class);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void init() {
|
||||
when(emf.createEntityManager()).thenReturn(em);
|
||||
when(emf.createEntityManager(any(Map.class))).thenReturn(em);
|
||||
when(emf.createEntityManager(any(SynchronizationType.class))).thenReturn(em);
|
||||
when(emf.createEntityManager(any(SynchronizationType.class), any(Map.class))).thenReturn(em);
|
||||
}
|
||||
|
||||
@Test
|
||||
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()
|
||||
));
|
||||
|
||||
// when
|
||||
mockMvc.perform(MockMvcRequestBuilders
|
||||
.get("/api/login/contexts")
|
||||
.header("Authorization", "Bearer superuser-alex@hostsharing.net")
|
||||
.accept(MediaType.APPLICATION_JSON))
|
||||
.andDo(print())
|
||||
|
||||
// then
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath(
|
||||
"$", lenientlyEquals("""
|
||||
[
|
||||
{
|
||||
"type": "HSADMIN",
|
||||
"qualifier": "prod"
|
||||
},
|
||||
{
|
||||
"type": "SSH",
|
||||
"qualifier": "prod"
|
||||
}
|
||||
]
|
||||
"""
|
||||
)));
|
||||
}
|
||||
}
|
@@ -0,0 +1,165 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextResource;
|
||||
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginContextTypeResource;
|
||||
import net.hostsharing.hsadminng.credentials.generated.api.v1.model.LoginCredentialsPatchResource;
|
||||
import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.TestInstance;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
|
||||
@TestInstance(PER_CLASS)
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HsCredentialsEntityPatcherUnitTest extends PatchUnitTestBase<
|
||||
LoginCredentialsPatchResource,
|
||||
HsCredentialsEntity
|
||||
> {
|
||||
|
||||
private static final UUID INITIAL_CREDENTIALS_UUID = UUID.randomUUID();
|
||||
|
||||
private static final Boolean INITIAL_ACTIVE = true;
|
||||
private static final String INITIAL_EMAIL_ADDRESS = "initial@example.com";
|
||||
private static final String INITIAL_TWO_FACTOR_AUTH = "initial_2fa";
|
||||
private static final String INITIAL_SMS_NUMBER = "initial_sms";
|
||||
private static final String INITIAL_PHONE_PASSWORD = "initial_phone_pw";
|
||||
|
||||
private static final Boolean PATCHED_ACTIVE = false;
|
||||
private static final String PATCHED_EMAIL_ADDRESS = "patched@example.com";
|
||||
private static final String PATCHED_TWO_FACTOR_AUTH = "patched_2fa";
|
||||
private static final String PATCHED_SMS_NUMBER = "patched_sms";
|
||||
private static final String PATCHED_PHONE_PASSWORD = "patched_phone_pw";
|
||||
|
||||
// Contexts
|
||||
private static final UUID CONTEXT_UUID_1 = UUID.randomUUID();
|
||||
private static final UUID CONTEXT_UUID_2 = UUID.randomUUID();
|
||||
private static final UUID CONTEXT_UUID_3 = UUID.randomUUID();
|
||||
|
||||
private final HsCredentialsContextRealEntity initialContextEntity1 = HsCredentialsContextRealEntity.builder()
|
||||
.uuid(CONTEXT_UUID_1)
|
||||
.type("HSADMIN")
|
||||
.qualifier("prod")
|
||||
.build();
|
||||
private final HsCredentialsContextRealEntity initialContextEntity2 = HsCredentialsContextRealEntity.builder()
|
||||
.uuid(CONTEXT_UUID_2)
|
||||
.type("SSH")
|
||||
.qualifier("dev")
|
||||
.build();
|
||||
|
||||
private LoginContextResource patchContextResource2;
|
||||
private LoginContextResource patchContextResource3;
|
||||
|
||||
// This is what em.find should return for CONTEXT_UUID_3
|
||||
private final HsCredentialsContextRealEntity newContextEntity3 = HsCredentialsContextRealEntity.builder()
|
||||
.uuid(CONTEXT_UUID_3)
|
||||
.type("HSADMIN")
|
||||
.qualifier("test")
|
||||
.build();
|
||||
|
||||
private final Set<HsCredentialsContextRealEntity> initialContextEntities = Set.of(initialContextEntity1, initialContextEntity2);
|
||||
private List<LoginContextResource> patchedContextResources;
|
||||
private final Set<HsCredentialsContextRealEntity> expectedPatchedContextEntities = Set.of(initialContextEntity2, newContextEntity3);
|
||||
|
||||
@Mock
|
||||
private EntityManager em;
|
||||
|
||||
@BeforeEach
|
||||
void initMocks() {
|
||||
// Mock em.find for contexts that are part of the patch and need to be fetched
|
||||
lenient().when(em.find(eq(HsCredentialsContextRealEntity.class), eq(CONTEXT_UUID_1))).thenReturn(initialContextEntity1);
|
||||
lenient().when(em.find(eq(HsCredentialsContextRealEntity.class), eq(CONTEXT_UUID_2))).thenReturn(initialContextEntity2);
|
||||
lenient().when(em.find(eq(HsCredentialsContextRealEntity.class), eq(CONTEXT_UUID_3))).thenReturn(newContextEntity3);
|
||||
|
||||
patchContextResource2 = new LoginContextResource();
|
||||
patchContextResource2.setUuid(CONTEXT_UUID_2);
|
||||
patchContextResource2.setType(LoginContextTypeResource.SSH);
|
||||
patchContextResource2.setQualifier("dev");
|
||||
|
||||
patchContextResource3 = new LoginContextResource();
|
||||
patchContextResource3.setUuid(CONTEXT_UUID_3);
|
||||
patchContextResource3.setType(LoginContextTypeResource.HSADMIN);
|
||||
patchContextResource3.setQualifier("test");
|
||||
|
||||
patchedContextResources = List.of(patchContextResource2, patchContextResource3);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HsCredentialsEntity newInitialEntity() {
|
||||
final var entity = new HsCredentialsEntity();
|
||||
entity.setUuid(INITIAL_CREDENTIALS_UUID);
|
||||
entity.setActive(INITIAL_ACTIVE);
|
||||
entity.setEmailAddress(INITIAL_EMAIL_ADDRESS);
|
||||
entity.setTwoFactorAuth(INITIAL_TWO_FACTOR_AUTH);
|
||||
entity.setSmsNumber(INITIAL_SMS_NUMBER);
|
||||
entity.setPhonePassword(INITIAL_PHONE_PASSWORD);
|
||||
// Ensure loginContexts is a mutable set for the patcher
|
||||
entity.setLoginContexts(new HashSet<>(initialContextEntities));
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected LoginCredentialsPatchResource newPatchResource() {
|
||||
return new LoginCredentialsPatchResource();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected HsCredentialsEntityPatcher createPatcher(final HsCredentialsEntity entity) {
|
||||
return new HsCredentialsEntityPatcher(em, entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Stream<Property> propertyTestDescriptors() {
|
||||
return Stream.of(
|
||||
new SimpleProperty<>(
|
||||
"active",
|
||||
LoginCredentialsPatchResource::setActive,
|
||||
PATCHED_ACTIVE,
|
||||
HsCredentialsEntity::setActive,
|
||||
PATCHED_ACTIVE)
|
||||
.notNullable(),
|
||||
new JsonNullableProperty<>(
|
||||
"emailAddress",
|
||||
LoginCredentialsPatchResource::setEmailAddress,
|
||||
PATCHED_EMAIL_ADDRESS,
|
||||
HsCredentialsEntity::setEmailAddress,
|
||||
PATCHED_EMAIL_ADDRESS),
|
||||
new JsonNullableProperty<>(
|
||||
"twoFactorAuth",
|
||||
LoginCredentialsPatchResource::setTwoFactorAuth,
|
||||
PATCHED_TWO_FACTOR_AUTH,
|
||||
HsCredentialsEntity::setTwoFactorAuth,
|
||||
PATCHED_TWO_FACTOR_AUTH),
|
||||
new JsonNullableProperty<>(
|
||||
"smsNumber",
|
||||
LoginCredentialsPatchResource::setSmsNumber,
|
||||
PATCHED_SMS_NUMBER,
|
||||
HsCredentialsEntity::setSmsNumber,
|
||||
PATCHED_SMS_NUMBER),
|
||||
new JsonNullableProperty<>(
|
||||
"phonePassword",
|
||||
LoginCredentialsPatchResource::setPhonePassword,
|
||||
PATCHED_PHONE_PASSWORD,
|
||||
HsCredentialsEntity::setPhonePassword,
|
||||
PATCHED_PHONE_PASSWORD),
|
||||
new SimpleProperty<>(
|
||||
"contexts",
|
||||
LoginCredentialsPatchResource::setContexts,
|
||||
patchedContextResources,
|
||||
HsCredentialsEntity::setLoginContexts,
|
||||
expectedPatchedContextEntities)
|
||||
.notNullable()
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,243 @@
|
||||
package net.hostsharing.hsadminng.credentials;
|
||||
|
||||
import net.hostsharing.hsadminng.context.Context;
|
||||
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRealEntity;
|
||||
import net.hostsharing.hsadminng.rbac.context.ContextBasedTest;
|
||||
import net.hostsharing.hsadminng.rbac.subject.RbacSubjectEntity;
|
||||
import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
|
||||
import org.hibernate.TransientObjectException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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 jakarta.persistence.NoResultException;
|
||||
import jakarta.persistence.Query;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
@DataJpaTest
|
||||
@Tag("generalIntegrationTest")
|
||||
@Import({ Context.class, JpaAttempt.class })
|
||||
class HsCredentialsRepositoryIntegrationTest extends ContextBasedTest {
|
||||
|
||||
private static final String SUPERUSER_ALEX_SUBJECT_NAME = "superuser-alex@hostsharing.net";
|
||||
private static final String USER_DREW_SUBJECT_NAME = "selfregistered-user-drew@hostsharing.org";
|
||||
private static final String TEST_USER_SUBJECT_NAME = "selfregistered-test-user@hostsharing.org";
|
||||
|
||||
// HOWTO fix UnsatisfiedDependencyException with cause "No qualifying bean of type 'jakarta.servlet.http.HttpServletRequest'"
|
||||
// This dependency comes from class net.hostsharing.hsadminng.context.Context,
|
||||
// which is not automatically wired in a @DataJpaTest, but just in @SpringBootTest.
|
||||
// If, e.g. for validators, the current user or assumed roles are needed, the values need to be mocked.
|
||||
@MockitoBean
|
||||
HttpServletRequest request;
|
||||
|
||||
@Autowired
|
||||
private HsCredentialsRepository loginCredentialsRepository;
|
||||
|
||||
@Autowired
|
||||
private HsCredentialsContextRealRepository loginContextRealRepo;
|
||||
|
||||
// fetched UUIDs from test-data
|
||||
private RbacSubjectEntity alexSubject;
|
||||
private RbacSubjectEntity drewSubject;
|
||||
private RbacSubjectEntity testUserSubject;
|
||||
private HsOfficePersonRealEntity drewPerson;
|
||||
private HsOfficePersonRealEntity testUserPerson;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
alexSubject = fetchSubjectByName(SUPERUSER_ALEX_SUBJECT_NAME);
|
||||
drewSubject = fetchSubjectByName(USER_DREW_SUBJECT_NAME);
|
||||
testUserSubject = fetchSubjectByName(TEST_USER_SUBJECT_NAME);
|
||||
drewPerson = fetchPersonByGivenName("Drew");
|
||||
testUserPerson = fetchPersonByGivenName("Test");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void historizationIsAvailable() {
|
||||
// given
|
||||
final String nativeQuerySql = "select * from hs_credentials.credentials_hv";
|
||||
|
||||
// when
|
||||
historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant()));
|
||||
final var query = em.createNativeQuery(nativeQuerySql);
|
||||
final var rowsBefore = query.getResultList();
|
||||
|
||||
// then
|
||||
assertThat(rowsBefore).as("hs_credentials.credentials_hv only contain no rows for a timestamp before test data creation").hasSize(0);
|
||||
|
||||
// and when
|
||||
historicalContext(Timestamp.from(ZonedDateTime.now().toInstant()));
|
||||
em.createNativeQuery(nativeQuerySql);
|
||||
final var rowsAfter = query.getResultList();
|
||||
|
||||
// then
|
||||
assertThat(rowsAfter).as("hs_credentials.credentials_hv should now contain the test-data rows for the current timestamp").hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFindByUuidUsingTestData() {
|
||||
// when
|
||||
final var foundEntityOptional = loginCredentialsRepository.findByUuid(alexSubject.getUuid());
|
||||
|
||||
// then
|
||||
assertThat(foundEntityOptional).isPresent()
|
||||
.map(HsCredentialsEntity::getEmailAddress).contains("alex@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSaveLoginCredentialsWithExistingContext() {
|
||||
// given
|
||||
final var existingContext = loginContextRealRepo.findByTypeAndQualifier("HSADMIN", "prod")
|
||||
.orElseThrow();
|
||||
final var newCredentials = HsCredentialsEntity.builder()
|
||||
.subject(drewSubject)
|
||||
.person(drewPerson)
|
||||
.active(true)
|
||||
.emailAddress("drew.new@example.com")
|
||||
.globalUid(2001)
|
||||
.globalGid(2001)
|
||||
.loginContexts(mutableSetOf(existingContext))
|
||||
.build();
|
||||
|
||||
// when
|
||||
loginCredentialsRepository.save(newCredentials);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// then
|
||||
final var foundEntityOptional = loginCredentialsRepository.findByUuid(drewSubject.getUuid());
|
||||
assertThat(foundEntityOptional).isPresent();
|
||||
final var foundEntity = foundEntityOptional.get();
|
||||
assertThat(foundEntity.getEmailAddress()).isEqualTo("drew.new@example.com");
|
||||
assertThat(foundEntity.isActive()).isTrue();
|
||||
assertThat(foundEntity.getVersion()).isEqualTo(0); // Initial version
|
||||
assertThat(foundEntity.getGlobalUid()).isEqualTo(2001);
|
||||
|
||||
assertThat(foundEntity.getLoginContexts()).hasSize(1)
|
||||
.map(HsCredentialsContextRealEntity::toString).contains("loginContext(HSADMIN:prod)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotSaveLoginCredentialsWithNewContext() {
|
||||
// given
|
||||
final var newContext = HsCredentialsContextRealEntity.builder()
|
||||
.type("MATRIX")
|
||||
.qualifier("forbidden")
|
||||
.build();
|
||||
final var newCredentials = HsCredentialsEntity.builder()
|
||||
.subject(drewSubject)
|
||||
.active(true)
|
||||
.emailAddress("drew.new@example.com")
|
||||
.globalUid(2001)
|
||||
.globalGid(2001)
|
||||
.loginContexts(mutableSetOf(newContext))
|
||||
.build();
|
||||
|
||||
// when
|
||||
final var exception = catchThrowable(() -> {
|
||||
loginCredentialsRepository.save(newCredentials);
|
||||
em.flush();
|
||||
});
|
||||
|
||||
// then
|
||||
assertThat(exception).isNotNull().hasCauseInstanceOf(TransientObjectException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSaveNewLoginCredentialsWithoutContext() {
|
||||
// given
|
||||
final var newCredentials = HsCredentialsEntity.builder()
|
||||
.subject(testUserSubject)
|
||||
.person(testUserPerson)
|
||||
.active(true)
|
||||
.emailAddress("test.user.new@example.com")
|
||||
.globalUid(20002)
|
||||
.globalGid(2002)
|
||||
.build();
|
||||
|
||||
// when
|
||||
loginCredentialsRepository.save(newCredentials);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// then
|
||||
final var foundEntityOptional = loginCredentialsRepository.findByUuid(testUserSubject.getUuid());
|
||||
assertThat(foundEntityOptional).isPresent();
|
||||
final var foundEntity = foundEntityOptional.get();
|
||||
assertThat(foundEntity.getEmailAddress()).isEqualTo("test.user.new@example.com");
|
||||
assertThat(foundEntity.isActive()).isTrue();
|
||||
assertThat(foundEntity.getGlobalUid()).isEqualTo(20002);
|
||||
assertThat(foundEntity.getGlobalGid()).isEqualTo(2002);
|
||||
assertThat(foundEntity.getLoginContexts()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUpdateExistingLoginCredentials() {
|
||||
// given
|
||||
final var entityToUpdate = loginCredentialsRepository.findByUuid(alexSubject.getUuid()).orElseThrow();
|
||||
final var initialVersion = entityToUpdate.getVersion();
|
||||
|
||||
// when
|
||||
entityToUpdate.setActive(false);
|
||||
entityToUpdate.setEmailAddress("updated.user1@example.com");
|
||||
final var savedEntity = loginCredentialsRepository.save(entityToUpdate);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// then
|
||||
assertThat(savedEntity.getVersion()).isGreaterThan(initialVersion);
|
||||
final var updatedEntityOptional = loginCredentialsRepository.findByUuid(alexSubject.getUuid());
|
||||
assertThat(updatedEntityOptional).isPresent();
|
||||
final var updatedEntity = updatedEntityOptional.get();
|
||||
assertThat(updatedEntity.isActive()).isFalse();
|
||||
assertThat(updatedEntity.getEmailAddress()).isEqualTo("updated.user1@example.com");
|
||||
}
|
||||
|
||||
|
||||
private RbacSubjectEntity fetchSubjectByName(final String name) {
|
||||
final String jpql = "SELECT s FROM RbacSubjectEntity s WHERE s.name = :name";
|
||||
final Query query = em.createQuery(jpql, RbacSubjectEntity.class);
|
||||
query.setParameter("name", name);
|
||||
try {
|
||||
context(SUPERUSER_ALEX_SUBJECT_NAME);
|
||||
return notNull((RbacSubjectEntity) query.getSingleResult());
|
||||
} catch (final NoResultException e) {
|
||||
throw new AssertionError(
|
||||
"Failed to find subject with name '" + name + "'. Ensure test data is present.", e);
|
||||
}
|
||||
}
|
||||
|
||||
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((HsOfficePersonRealEntity) query.getSingleResult());
|
||||
} catch (final NoResultException e) {
|
||||
throw new AssertionError(
|
||||
"Failed to find person with name '" + givenName + "'. Ensure test data is present.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T notNull(final T result) {
|
||||
assertThat(result).isNotNull();
|
||||
return result;
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private <T> Set<T> mutableSetOf(final T... elements) {
|
||||
return new HashSet<T>(Set.of(elements));
|
||||
}
|
||||
}
|
@@ -92,26 +92,23 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup
|
||||
@Test
|
||||
public void historizationIsAvailable() {
|
||||
// given
|
||||
final String nativeQuerySql = """
|
||||
select count(*)
|
||||
from hs_booking.item_hv ha;
|
||||
""";
|
||||
final String nativeQuerySql = "select * from hs_booking.item_hv";
|
||||
|
||||
// when
|
||||
historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant()));
|
||||
final var query = em.createNativeQuery(nativeQuerySql, Integer.class);
|
||||
@SuppressWarnings("unchecked") final var countBefore = (Integer) query.getSingleResult();
|
||||
final var query = em.createNativeQuery(nativeQuerySql);
|
||||
final var rowsBefore = query.getResultList();
|
||||
|
||||
// then
|
||||
assertThat(countBefore).as("hs_booking.item should not contain rows for a timestamp in the past").isEqualTo(0);
|
||||
assertThat(rowsBefore).as("hs_booking.item should not contain rows for a timestamp in the past").hasSize(0);
|
||||
|
||||
// and when
|
||||
historicalContext(Timestamp.from(ZonedDateTime.now().plusHours(1).toInstant()));
|
||||
em.createNativeQuery(nativeQuerySql, Integer.class);
|
||||
@SuppressWarnings("unchecked") final var countAfter = (Integer) query.getSingleResult();
|
||||
em.createNativeQuery(nativeQuerySql);
|
||||
final var rowsAfter = query.getResultList();
|
||||
|
||||
// then
|
||||
assertThat(countAfter).as("hs_booking.item should contain rows for a timestamp in the future").isGreaterThan(1);
|
||||
assertThat(rowsAfter).as("hs_booking.item should contain rows for a timestamp in the future").hasSize(21);
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
@@ -83,26 +83,23 @@ class HsBookingProjectRepositoryIntegrationTest extends ContextBasedTestWithClea
|
||||
@Test
|
||||
public void historizationIsAvailable() {
|
||||
// given
|
||||
final String nativeQuerySql = """
|
||||
select count(*)
|
||||
from hs_booking.project_hv ha;
|
||||
""";
|
||||
final String nativeQuerySql = "select * from hs_booking.project_hv ha";
|
||||
|
||||
// when
|
||||
historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant()));
|
||||
final var query = em.createNativeQuery(nativeQuerySql, Integer.class);
|
||||
@SuppressWarnings("unchecked") final var countBefore = (Integer) query.getSingleResult();
|
||||
final var query = em.createNativeQuery(nativeQuerySql);
|
||||
final var rowsBefore = query.getResultList();
|
||||
|
||||
// then
|
||||
assertThat(countBefore).as("hs_booking.project_hv should not contain rows for a timestamp in the past").isEqualTo(0);
|
||||
assertThat(rowsBefore).as("hs_booking.project_hv should not contain rows for a timestamp in the past").hasSize(0);
|
||||
|
||||
// and when
|
||||
historicalContext(Timestamp.from(ZonedDateTime.now().plusHours(1).toInstant()));
|
||||
em.createNativeQuery(nativeQuerySql, Integer.class);
|
||||
@SuppressWarnings("unchecked") final var countAfter = (Integer) query.getSingleResult();
|
||||
final var rowsAfter = query.getResultList();
|
||||
|
||||
// then
|
||||
assertThat(countAfter).as("hs_booking.project_hv should contain rows for a timestamp in the future").isGreaterThan(1);
|
||||
assertThat(rowsAfter).as("hs_booking.project_hv should contain test-data rows for current timestamp").hasSize(3);
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
@@ -113,26 +113,23 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu
|
||||
@Test
|
||||
public void historizationIsAvailable() {
|
||||
// given
|
||||
final String nativeQuerySql = """
|
||||
select count(*)
|
||||
from hs_hosting.asset_hv ha;
|
||||
""";
|
||||
final String nativeQuerySql = "select * from hs_hosting.asset_hv";
|
||||
|
||||
// when
|
||||
historicalContext(Timestamp.from(ZonedDateTime.now().minusDays(1).toInstant()));
|
||||
final var query = em.createNativeQuery(nativeQuerySql, Integer.class);
|
||||
@SuppressWarnings("unchecked") final var countBefore = (Integer) query.getSingleResult();
|
||||
final var query = em.createNativeQuery(nativeQuerySql);
|
||||
@SuppressWarnings("unchecked") final var rowsBefore = query.getResultList();
|
||||
|
||||
// then
|
||||
assertThat(countBefore).as("hs_hosting.asset_hv should not contain rows for a timestamp in the past").isEqualTo(0);
|
||||
assertThat(rowsBefore).as("hs_hosting.asset_hv should not contain rows for a timestamp in the past").hasSize(0);
|
||||
|
||||
// and when
|
||||
historicalContext(Timestamp.from(ZonedDateTime.now().plusHours(1).toInstant()));
|
||||
historicalContext(Timestamp.from(ZonedDateTime.now().toInstant()));
|
||||
em.createNativeQuery(nativeQuerySql, Integer.class);
|
||||
@SuppressWarnings("unchecked") final var countAfter = (Integer) query.getSingleResult();
|
||||
@SuppressWarnings("unchecked") final var rowsAfter = query.getResultList();
|
||||
|
||||
// then
|
||||
assertThat(countAfter).as("hs_hosting.asset_hv should contain rows for a timestamp in the future").isGreaterThan(1);
|
||||
assertThat(rowsAfter).as("hs_hosting.asset_hv should contain test-data rows for current timestamp").hasSize(54);
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
@@ -68,7 +68,7 @@ class HsOfficePersonControllerAcceptanceTest extends ContextBasedTestWithCleanup
|
||||
.then().log().all().assertThat()
|
||||
.statusCode(200)
|
||||
.contentType("application/json")
|
||||
.body("", hasSize(13));
|
||||
.body("", hasSize(17));
|
||||
// @formatter:on
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user