diff --git a/build.gradle b/build.gradle index 332a5410..63f4a996 100644 --- a/build.gradle +++ b/build.gradle @@ -66,6 +66,7 @@ dependencies { implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0' implementation 'org.openapitools:jackson-databind-nullable:0.2.6' implementation 'org.apache.commons:commons-text:1.11.0' + implementation 'net.java.dev.jna:jna:5.8.0' implementation 'org.modelmapper:modelmapper:3.2.0' implementation 'org.iban4j:iban4j:3.2.7-RELEASE' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' diff --git a/doc/hs-hosting-asset-type-structure.md b/doc/hs-hosting-asset-type-structure.md new file mode 100644 index 00000000..f2af876b --- /dev/null +++ b/doc/hs-hosting-asset-type-structure.md @@ -0,0 +1,199 @@ +## HostingAsset Type Structure + +### Domain + +```plantuml +@startuml +left to right direction + +package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + entity BI_DOMAIN_DNS_SETUP + entity BI_DOMAIN_SMTP_SETUP +} + +package Hosting #feb28c{ + package Domain #99bcdb { + entity HA_DOMAIN_SETUP + entity HA_DOMAIN_DNS_SETUP + entity HA_DOMAIN_HTTP_SETUP + entity HA_DOMAIN_SMTP_SETUP + entity HA_DOMAIN_MBOX_SETUP + entity HA_EMAIL_ADDRESS + } + + package Server #99bcdb { + entity HA_CLOUD_SERVER + entity HA_MANAGED_SERVER + entity HA_IP_NUMBER + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + +} + +BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + +HA_CLOUD_SERVER ==* BI_CLOUD_SERVER +HA_MANAGED_SERVER ==* BI_MANAGED_SERVER +HA_MANAGED_WEBSPACE ==* BI_MANAGED_WEBSPACE +HA_MANAGED_WEBSPACE o..> HA_MANAGED_SERVER +HA_UNIX_USER *==> HA_MANAGED_WEBSPACE +HA_DOMAIN_SETUP o..> HA_DOMAIN_SETUP +HA_DOMAIN_DNS_SETUP *==> HA_DOMAIN_SETUP +HA_DOMAIN_HTTP_SETUP *==> HA_DOMAIN_SETUP +HA_DOMAIN_HTTP_SETUP o..> HA_UNIX_USER +HA_DOMAIN_SMTP_SETUP *==> HA_DOMAIN_SETUP +HA_DOMAIN_SMTP_SETUP o..> HA_MANAGED_WEBSPACE +HA_DOMAIN_MBOX_SETUP *==> HA_DOMAIN_SETUP +HA_DOMAIN_MBOX_SETUP o..> HA_MANAGED_WEBSPACE +HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE +HA_EMAIL_ADDRESS *==> HA_DOMAIN_MBOX_SETUP +HA_IP_NUMBER o..> HA_CLOUD_SERVER +HA_IP_NUMBER o..> HA_MANAGED_SERVER +HA_IP_NUMBER o..> HA_MANAGED_WEBSPACE + +package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 +} +Booking -down[hidden]->Legend +``` +### MariaDB + +```plantuml +@startuml +left to right direction + +package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + entity BI_DOMAIN_DNS_SETUP + entity BI_DOMAIN_SMTP_SETUP +} + +package Hosting #feb28c{ + package MariaDB #99bcdb { + entity HA_MARIADB_INSTANCE + entity HA_MARIADB_USER + entity HA_MARIADB_DATABASE + } + + package Server #99bcdb { + entity HA_CLOUD_SERVER + entity HA_MANAGED_SERVER + entity HA_IP_NUMBER + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + +} + +BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + +HA_CLOUD_SERVER ==* BI_CLOUD_SERVER +HA_MANAGED_SERVER ==* BI_MANAGED_SERVER +HA_MANAGED_WEBSPACE ==* BI_MANAGED_WEBSPACE +HA_MANAGED_WEBSPACE o..> HA_MANAGED_SERVER +HA_UNIX_USER *==> HA_MANAGED_WEBSPACE +HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE +HA_MARIADB_INSTANCE *==> HA_MANAGED_SERVER +HA_MARIADB_USER *==> HA_MARIADB_INSTANCE +HA_MARIADB_USER o..> HA_MANAGED_WEBSPACE +HA_MARIADB_DATABASE *==> HA_MANAGED_WEBSPACE +HA_MARIADB_DATABASE o..> HA_MARIADB_INSTANCE +HA_IP_NUMBER o..> HA_CLOUD_SERVER +HA_IP_NUMBER o..> HA_MANAGED_SERVER +HA_IP_NUMBER o..> HA_MANAGED_WEBSPACE + +package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 +} +Booking -down[hidden]->Legend +``` +### PostgreSQL + +```plantuml +@startuml +left to right direction + +package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + entity BI_DOMAIN_DNS_SETUP + entity BI_DOMAIN_SMTP_SETUP +} + +package Hosting #feb28c{ + package PostgreSQL #99bcdb { + entity HA_PGSQL_INSTANCE + entity HA_PGSQL_USER + entity HA_PGSQL_DATABASE + } + + package Server #99bcdb { + entity HA_CLOUD_SERVER + entity HA_MANAGED_SERVER + entity HA_IP_NUMBER + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + +} + +BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD +BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + +HA_CLOUD_SERVER ==* BI_CLOUD_SERVER +HA_MANAGED_SERVER ==* BI_MANAGED_SERVER +HA_MANAGED_WEBSPACE ==* BI_MANAGED_WEBSPACE +HA_MANAGED_WEBSPACE o..> HA_MANAGED_SERVER +HA_UNIX_USER *==> HA_MANAGED_WEBSPACE +HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE +HA_PGSQL_INSTANCE *==> HA_MANAGED_SERVER +HA_PGSQL_USER *==> HA_PGSQL_INSTANCE +HA_PGSQL_USER o..> HA_MANAGED_WEBSPACE +HA_PGSQL_DATABASE *==> HA_MANAGED_WEBSPACE +HA_PGSQL_DATABASE o..> HA_PGSQL_INSTANCE +HA_IP_NUMBER o..> HA_CLOUD_SERVER +HA_IP_NUMBER o..> HA_MANAGED_SERVER +HA_IP_NUMBER o..> HA_MANAGED_WEBSPACE + +package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 +} +Booking -down[hidden]->Legend +``` + This code generated was by HsHostingAssetType.main, do not amend manually. diff --git a/doc/projects-booking-items-and-hosting-entities.md b/doc/projects-booking-items-and-hosting-entities.md new file mode 100644 index 00000000..e2a2ba83 --- /dev/null +++ b/doc/projects-booking-items-and-hosting-entities.md @@ -0,0 +1,288 @@ +## HSAdmin-NG +### Project/BookingItems/HostingEntities + +__ATTENTION__: The notation uses UML clas diagram elements, but partly with different meanings. See Agenda. + +```mermaid +classDiagram + direction TD + + Partner o-- "0..n" Membership + Partner *-- "1..n" Debitor + Debitor *-- "1..n" Project + + Project o-- "0..n" PrivateCloudBI + Project o-- "0..n" CloudServerBI + Project o-- "0..n" ManagedServerBI + Project o-- "0..n" ManagedWebspaceBI + + PrivateCloudBI o-- "0..n" ManagedServerBI + PrivateCloudBI o-- "0..n" CloudServerBI + + CloudServerBI *-- CloudServerHE + + ManagedServerBI *-- ManagedServerHE + ManagedServerBI o-- "0..n" ManagedWebspaceBI + ManagedWebspaceBI *-- ManagedWebspaceHE + + ManagedWebspaceHE *-- "1..n" UnixUserHE + ManagedWebspaceHE o-- "0..n" DomainDNSSetupHE + ManagedWebspaceHE o-- "0..n" DomainHttpSetupHE + ManagedWebspaceHE o-- "0..n" DomainEMailSetupHE + ManagedWebspaceHE o-- "0..n" EMailAliasHE + DomainEMailSetupHE o-- "0..n" EMailAddressHE + ManagedWebspaceHE o-- "0..n" MariaDBUserHE + MariaDBUserHE o-- "0..n" MariaDBHE + ManagedWebspaceHE o-- "0..n" PostgresDBUserHE + PostgresDBUserHE o-- "0..n" PostgresDBHE + + DomainHttpSetupHE --|> UnixUserHE : assignedToAsset + + ManagedWebspaceHE --|> ManagedServerHE + + namespace Office { + class Partner { + } + + class Membership { + } + + class Debitor { + + } + } + + namespace Booking { + class Project { + +caption + +create() + } + class PrivateCloudBI { + +caption + ~resources = [ + ⠀⠀+CPUs + ⠀⠀+RAM + ⠀⠀+SSD + ⠀⠀+HDD + ⠀⠀+Traffic + ] + + +book() + } + class CloudServerBI { + +caption + ~resources = [ + ⠀⠀+CPUs + ⠀⠀+RAM + ⠀⠀+SSD + ⠀⠀+HDD + ⠀⠀+Traffic + ] + + +book() + } + class ManagedServerBI { + +caption + ~respources = [ + ⠀⠀+CPUs + ⠀⠀+RAM + ⠀⠀+SSD + ⠀⠀+HDD + ⠀⠀+Traffic + ] + + +book() + } + class ManagedWebspaceBI { + +caption + ~resources = [ + ⠀⠀+SSD + ⠀⠀+HDD + ⠀⠀+Traffic + ⠀⠀+MultiOptions + ⠀⠀+Daemons + ] + + +book() + } + } + + style Project stroke:blue,stroke-width:4px + style PrivateCloudBI stroke:blue,stroke-width:4px + style CloudServerBI stroke:blue,stroke-width:4px + style ManagedServerBI stroke:blue,stroke-width:4px + style ManagedWebspaceBI stroke:blue,stroke-width:4px + + %% --------------------------------------------------------- + + namespace HostingServers { + %% separate (pseudo-) namespace just for better rendering + + class CloudServerHE { + -identifier, e.g. "vm1234" + -caption := bi.caption? + -parentAsset := parentHost + -identifier := serverName + -create() + } + class ManagedServerHE { + -identifier, e.g. "vm1234" + -caption := bi.caption? + -parentAsset := parentHost + -identifier := serverName + ~config = [ + ⠀⠀+installed Software + ] + -create() + } + } + + namespace Hosting { + class ManagedWebspaceHE { + -parentAsset := parentManagedServer + -identifier : webspaceName + +caption + + -create() + } + + class UnixUserHE { + +identifier ["xyz00-..."] + +caption + ~config = [ + ⠀⠀+SSD Soft Quota + ⠀⠀+SSD Hard Quota + ⠀⠀+HDD Soft Quota + ⠀⠀+HDD Hard Quota + ⠀⠀#shell + ⠀⠀#password + ] + + +create() + } + class DomainDNSSetupHE { + +identifier, e.g. "example.com" + +caption + + +create() + } + class DomainHttpSetupHE { + +identifier, e.g. "example.com" + +caption + + +create() + } + class DomainEMailSetupHE { + +identifier, e.g. "example.com" + +caption + + +create() + } + class EMailAliasHE { + +identifier, e.g "xyz00-..." + +caption + + ~config = [ + ⠀⠀+target[] + ] + + +create() + } + class EMailAddressHE { + +identifier, e.g. "test@example.org" + +caption + ~config = [ + ⠀⠀+sub-domain + ⠀⠀+local-part + ⠀⠀+target + ] + + +create() + } + class MariaDBUserHE { + +identifier, e.g. "xyz00_mydb" + +caption + config = [ + ⠀⠀#password + ] + + +create() + } + class MariaDBHE { + +identifier, e.g. "xyz00_mydb" + +caption + ~config = [ + ⠀⠀+encoding + ] + + +create() + } + class PostgresDBUserHE { + +identifier, e.g. "xyz00_mydb" + +caption + ~config = [ + ⠀⠀#password + ] + + +create() + } + class PostgresDBHE { + +identifier, e.g. "xyz00_mydb" + +caption + + ~config = [ + ⠀⠀+encoding + ⠀⠀+extensions + ] + +create() + } + } + + style CloudServerHE stroke:orange,stroke-width:4px + style ManagedServerHE stroke:orange,stroke-width:4px + style ManagedWebspaceHE stroke:orange,stroke-width:4px + style UnixUserHE stroke:blue,stroke-width:4px + style DomainDNSSetupHE stroke:blue,stroke-width:4px + style DomainHttpSetupHE stroke:blue,stroke-width:4px + style DomainEMailSetupHE stroke:blue,stroke-width:4px + style EMailAliasHE stroke:blue,stroke-width:4px + style EMailAddressHE stroke:blue,stroke-width:4px + style MariaDBUserHE stroke:blue,stroke-width:4px + style MariaDBHE stroke:blue,stroke-width:4px + style PostgresDBUserHE stroke:blue,stroke-width:4px + style PostgresDBHE stroke:blue,stroke-width:4px + +%% -------------------------------------- + + ParentA o-- ChildA : can contain + ParentB *-- ChildB : contains + + namespace Agenda { + class ParentA { + } + class ChildA { + } + class ParentB { + } + class ChildB { + } + class CreatedByClient { + } + class CreatedAutomatically { + } + class SomeEntity { + ~patchable = [ + %% the following indentations uses two U+2800 to have effect in the rendered diagram + ⠀⠀+first + ⠀⠀+second + ] + -readOnly for client accounts + +readWrite for client accounts + #writeOnly + } + } + + style CreatedByClient stroke:blue,stroke-width:4px + style CreatedAutomatically stroke:orange,stroke-width:4px +end +``` diff --git a/src/main/java/net/hostsharing/hsadminng/errors/CustomErrorResponse.java b/src/main/java/net/hostsharing/hsadminng/errors/CustomErrorResponse.java index 2714b817..9b182137 100644 --- a/src/main/java/net/hostsharing/hsadminng/errors/CustomErrorResponse.java +++ b/src/main/java/net/hostsharing/hsadminng/errors/CustomErrorResponse.java @@ -9,7 +9,7 @@ import org.springframework.web.context.request.WebRequest; import java.time.LocalDateTime; @Getter -class CustomErrorResponse { +public class CustomErrorResponse { static ResponseEntity errorResponse( final WebRequest request, diff --git a/src/main/java/net/hostsharing/hsadminng/errors/MultiValidationException.java b/src/main/java/net/hostsharing/hsadminng/errors/MultiValidationException.java new file mode 100644 index 00000000..c8e721a2 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/errors/MultiValidationException.java @@ -0,0 +1,23 @@ +package net.hostsharing.hsadminng.errors; + +import jakarta.validation.ValidationException; +import java.util.List; + +import static java.lang.String.join; + +public class MultiValidationException extends ValidationException { + + private MultiValidationException(final List violations) { + super( + violations.size() > 1 + ? "[\n" + join(",\n", violations) + "\n]" + : "[" + join(",\n", violations) + "]" + ); + } + + public static void throwIfNotEmpty(final List violations) { + if (!violations.isEmpty()) { + throw new MultiValidationException(violations); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java b/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java index 5d675484..d4d6e8bf 100644 --- a/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java +++ b/src/main/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandler.java @@ -73,9 +73,10 @@ public class RestResponseEntityExceptionHandler } @ExceptionHandler({ Iban4jException.class, ValidationException.class }) - protected ResponseEntity handleIbanAndBicExceptions( + protected ResponseEntity handleValidationExceptions( final Throwable exc, final WebRequest request) { - final var message = line(NestedExceptionUtils.getMostSpecificCause(exc).getMessage(), 0); + final String fullMessage = NestedExceptionUtils.getMostSpecificCause(exc).getMessage(); + final var message = exc instanceof MultiValidationException ? fullMessage : line(fullMessage, 0); return errorResponse(request, HttpStatus.BAD_REQUEST, message); } diff --git a/src/main/java/net/hostsharing/hsadminng/hash/LinuxEtcShadowHashGenerator.java b/src/main/java/net/hostsharing/hsadminng/hash/LinuxEtcShadowHashGenerator.java new file mode 100644 index 00000000..c030b830 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hash/LinuxEtcShadowHashGenerator.java @@ -0,0 +1,112 @@ +package net.hostsharing.hsadminng.hash; + +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.PriorityQueue; +import java.util.Queue; +import java.util.random.RandomGenerator; + +import com.sun.jna.Library; +import com.sun.jna.Native; + +public class LinuxEtcShadowHashGenerator { + + private static final RandomGenerator random = new SecureRandom(); + private static final Queue predefinedSalts = new PriorityQueue<>(); + + public static final int SALT_LENGTH = 16; + + private final String plaintextPassword; + private Algorithm algorithm; + + public enum Algorithm { + SHA512("6"), + YESCRYPT("y"); + + final String prefix; + + Algorithm(final String prefix) { + this.prefix = prefix; + } + + static Algorithm byPrefix(final String prefix) { + return Arrays.stream(Algorithm.values()).filter(a -> a.prefix.equals(prefix)).findAny() + .orElseThrow(() -> new IllegalArgumentException("unknown hash algorithm: '" + prefix + "'")); + } + } + + private static final String SALT_CHARACTERS = + "abcdefghijklmnopqrstuvwxyz" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "0123456789/."; + + private String salt; + + public static LinuxEtcShadowHashGenerator hash(final String plaintextPassword) { + return new LinuxEtcShadowHashGenerator(plaintextPassword); + } + + private LinuxEtcShadowHashGenerator(final String plaintextPassword) { + this.plaintextPassword = plaintextPassword; + } + + public LinuxEtcShadowHashGenerator using(final Algorithm algorithm) { + this.algorithm = algorithm; + return this; + } + + void verify(final String givenHash) { + final var parts = givenHash.split("\\$"); + if (parts.length < 3 || parts.length > 5) { + throw new IllegalArgumentException("not a " + algorithm.name() + " Linux hash: " + givenHash); + } + + algorithm = Algorithm.byPrefix(parts[1]); + salt = parts.length == 4 ? parts[2] : parts[2] + "$" + parts[3]; + + if (!generate().equals(givenHash)) { + throw new IllegalArgumentException("invalid password"); + } + } + + public String generate() { + if (salt == null) { + throw new IllegalStateException("no salt given"); + } + if (plaintextPassword == null) { + throw new IllegalStateException("no password given"); + } + + return NativeCryptLibrary.INSTANCE.crypt(plaintextPassword, "$" + algorithm.prefix + "$" + salt); + } + + public static void nextSalt(final String salt) { + predefinedSalts.add(salt); + } + + public LinuxEtcShadowHashGenerator withSalt(final String salt) { + this.salt = salt; + return this; + } + + public LinuxEtcShadowHashGenerator withRandomSalt() { + if (!predefinedSalts.isEmpty()) { + return withSalt(predefinedSalts.poll()); + } + final var stringBuilder = new StringBuilder(SALT_LENGTH); + for (int i = 0; i < SALT_LENGTH; ++i) { + int randomIndex = random.nextInt(SALT_CHARACTERS.length()); + stringBuilder.append(SALT_CHARACTERS.charAt(randomIndex)); + } + return withSalt(stringBuilder.toString()); + } + public static void main(String[] args) { + System.out.println(NativeCryptLibrary.INSTANCE.crypt("given password", "$6$abcdefghijklmno")); + } + + public interface NativeCryptLibrary extends Library { + NativeCryptLibrary INSTANCE = Native.load("crypt", NativeCryptLibrary.class); + + String crypt(String password, String salt); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorEntity.java new file mode 100644 index 00000000..3bc83ee6 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorEntity.java @@ -0,0 +1,55 @@ +package net.hostsharing.hsadminng.hs.booking.debitor; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import net.hostsharing.hsadminng.errors.DisplayName; +import net.hostsharing.hsadminng.stringify.Stringify; +import net.hostsharing.hsadminng.stringify.Stringifyable; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; + +import static net.hostsharing.hsadminng.stringify.Stringify.stringify; + +// a partial HsOfficeDebitorEntity to reduce the number of SQL queries to load the entity +@Entity +@Table(name = "hs_booking_debitor_rv") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DisplayName("BookingDebitor") +public class HsBookingDebitorEntity implements Stringifyable { + + public static final String DEBITOR_NUMBER_TAG = "D-"; + + private static Stringify stringify = + stringify(HsBookingDebitorEntity.class, "booking-debitor") + .withIdProp(HsBookingDebitorEntity::toShortString) + .withProp(HsBookingDebitorEntity::getDefaultPrefix) + .quotedValues(false); + + @Id + private UUID uuid; + + @Column(name = "debitornumber") + private Integer debitorNumber; + + @Column(name = "defaultprefix", columnDefinition = "char(3) not null") + private String defaultPrefix; + + @Override + public String toString() { + return stringify.apply(this); + } + + @Override + public String toShortString() { + return DEBITOR_NUMBER_TAG + debitorNumber; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorRepository.java new file mode 100644 index 00000000..f69dd72f --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorRepository.java @@ -0,0 +1,14 @@ +package net.hostsharing.hsadminng.hs.booking.debitor; + +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsBookingDebitorRepository extends Repository { + + Optional findByUuid(UUID id); + + List findByDebitorNumber(int debitorNumber); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java index e3154f76..36c16a32 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemController.java @@ -5,6 +5,7 @@ import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingItemsA import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemInsertResource; import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemPatchResource; import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemResource; +import net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidatorRegistry; import net.hostsharing.hsadminng.mapper.KeyValueMap; import net.hostsharing.hsadminng.mapper.Mapper; import org.springframework.beans.factory.annotation.Autowired; @@ -13,11 +14,13 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.time.LocalDate; import java.util.List; import java.util.UUID; import java.util.function.BiConsumer; -import static net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidators.valid; import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange; @RestController @@ -32,15 +35,18 @@ public class HsBookingItemController implements HsBookingItemsApi { @Autowired private HsBookingItemRepository bookingItemRepo; + @PersistenceContext + private EntityManager em; + @Override @Transactional(readOnly = true) - public ResponseEntity> listBookingItemsByDebitorUuid( + public ResponseEntity> listBookingItemsByProjectUuid( final String currentUser, final String assumedRoles, - final UUID debitorUuid) { + final UUID projectUuid) { context.define(currentUser, assumedRoles); - final var entities = bookingItemRepo.findAllByDebitorUuid(debitorUuid); + final var entities = bookingItemRepo.findAllByProjectUuid(projectUuid); final var resources = mapper.mapList(entities, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(resources); @@ -57,7 +63,7 @@ public class HsBookingItemController implements HsBookingItemsApi { final var entityToSave = mapper.map(body, HsBookingItemEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); - final var saved = bookingItemRepo.save(valid(entityToSave)); + final var saved = HsBookingItemEntityValidatorRegistry.validated(bookingItemRepo.save(entityToSave)); final var uri = MvcUriComponentsBuilder.fromController(getClass()) @@ -78,6 +84,7 @@ public class HsBookingItemController implements HsBookingItemsApi { context.define(currentUser, assumedRoles); final var result = bookingItemRepo.findByUuid(bookingItemUuid); + result.ifPresent(entity -> em.detach(entity)); // prevent further LAZY-loading return result .map(bookingItemEntity -> ResponseEntity.ok( mapper.map(bookingItemEntity, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER))) @@ -112,7 +119,7 @@ public class HsBookingItemController implements HsBookingItemsApi { new HsBookingItemEntityPatcher(current).apply(body); - final var saved = bookingItemRepo.save(valid(current)); + final var saved = bookingItemRepo.save(HsBookingItemEntityValidatorRegistry.validated(current)); final var mapped = mapper.map(saved, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(mapped); } @@ -124,9 +131,8 @@ public class HsBookingItemController implements HsBookingItemsApi { } }; - @SuppressWarnings("unchecked") final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { - entity.setValidity(toPostgresDateRange(resource.getValidFrom(), resource.getValidTo())); + entity.setValidity(toPostgresDateRange(LocalDate.now(), resource.getValidTo())); entity.putResources(KeyValueMap.from(resource.getResources())); }; } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntity.java index 60dd2935..ba1d2a7e 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntity.java @@ -9,9 +9,9 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; -import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationEntity; -import net.hostsharing.hsadminng.hs.validation.Validatable; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; +import net.hostsharing.hsadminng.hs.validation.PropertiesProvider; import net.hostsharing.hsadminng.mapper.PatchableMapWrapper; import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; @@ -20,32 +20,37 @@ import net.hostsharing.hsadminng.stringify.Stringify; import net.hostsharing.hsadminng.stringify.Stringifyable; import org.hibernate.annotations.Type; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import jakarta.persistence.Transient; import jakarta.persistence.Version; +import jakarta.validation.constraints.NotNull; import java.io.IOException; import java.time.LocalDate; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; +import static java.util.Collections.emptyMap; import static java.util.Optional.ofNullable; -import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.DEBITOR; import static net.hostsharing.hsadminng.mapper.PostgresDateRange.lowerInclusiveFromPostgresDateRange; import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange; import static net.hostsharing.hsadminng.mapper.PostgresDateRange.upperInclusiveFromPostgresDateRange; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; -import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingCase; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; -import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NULLABLE; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.DELETE; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.INSERT; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.SELECT; @@ -55,21 +60,20 @@ import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.AGENT; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.OWNER; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.TENANT; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; -import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.fetchedBySql; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; import static net.hostsharing.hsadminng.stringify.Stringify.stringify; -@Builder @Entity +@Builder(toBuilder = true) @Table(name = "hs_booking_item_rv") @Getter @Setter @NoArgsConstructor @AllArgsConstructor -public class HsBookingItemEntity implements Stringifyable, RbacObject, Validatable { +public class HsBookingItemEntity implements Stringifyable, RbacObject, PropertiesProvider { private static Stringify stringify = stringify(HsBookingItemEntity.class) - .withProp(HsBookingItemEntity::getDebitor) + .withProp(HsBookingItemEntity::getProject) .withProp(HsBookingItemEntity::getType) .withProp(e -> e.getValidity().asString()) .withProp(HsBookingItemEntity::getCaption) @@ -83,10 +87,15 @@ public class HsBookingItemEntity implements Stringifyable, RbacObject, Validatab @Version private int version; - @ManyToOne(optional = false) - @JoinColumn(name = "debitoruuid") - private HsOfficeDebitorEntity debitor; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "projectuuid") + private HsBookingProjectEntity project; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parentitemuuid") + private HsBookingItemEntity parentItem; + + @NotNull @Column(name = "type") @Enumerated(EnumType.STRING) private HsBookingItemType type; @@ -94,7 +103,7 @@ public class HsBookingItemEntity implements Stringifyable, RbacObject, Validatab @Builder.Default @Type(PostgreSQLRangeType.class) @Column(name = "validity", columnDefinition = "daterange") - private Range validity = Range.emptyRange(LocalDate.class); + private Range validity = Range.closedInfinite(LocalDate.now()); @Column(name = "caption") private String caption; @@ -105,6 +114,13 @@ public class HsBookingItemEntity implements Stringifyable, RbacObject, Validatab @Column(columnDefinition = "resources") private Map resources = new HashMap<>(); + @OneToMany(cascade = CascadeType.REFRESH, orphanRemoval = true) + @JoinColumn(name="parentitemuuid", referencedColumnName="uuid") + private List subBookingItems; + + @OneToOne(mappedBy="bookingItem") + private HsHostingAssetEntity relatedHostingAsset; + @Transient private PatchableMapWrapper resourcesWrapper; @@ -132,6 +148,23 @@ public class HsBookingItemEntity implements Stringifyable, RbacObject, Validatab return upperInclusiveFromPostgresDateRange(getValidity()); } + @Override + public Map directProps() { + return resources; + } + + @Override + public Object getContextValue(final String propName) { + final var v = resources.get(propName); + if (v!= null) { + return v; + } + if (parentItem!=null) { + return parentItem.getResources().get(propName); + } + return emptyMap(); + } + @Override public String toString() { return stringify.apply(this); @@ -139,64 +172,59 @@ public class HsBookingItemEntity implements Stringifyable, RbacObject, Validatab @Override public String toShortString() { - return ofNullable(debitor).map(HsOfficeDebitorEntity::toShortString).orElse("D-???????") + + return ofNullable(relatedProject()).map(HsBookingProjectEntity::toShortString).orElse("D-???????-?") + ":" + caption; } - @Override - public String getPropertiesName() { - return "resources"; + private HsBookingProjectEntity relatedProject() { + if (project != null) { + return project; + } + return parentItem == null ? null : parentItem.relatedProject(); } - @Override - public Map getProperties() { - return resources; + public HsBookingProjectEntity getRelatedProject() { + return project != null ? project : parentItem.getRelatedProject(); } public static RbacView rbac() { return rbacViewFor("bookingItem", HsBookingItemEntity.class) - .withIdentityView(SQL.query(""" - SELECT bookingItem.uuid as uuid, debitorIV.idName || '-' || cleanIdentifier(bookingItem.caption) as idName - FROM hs_booking_item bookingItem - JOIN hs_office_debitor_iv debitorIV ON debitorIV.uuid = bookingItem.debitorUuid - """)) + .withIdentityView(SQL.projection("caption")) .withRestrictedViewOrderBy(SQL.expression("validity")) .withUpdatableColumns("version", "caption", "validity", "resources") - - .importEntityAlias("debitor", HsOfficeDebitorEntity.class, usingDefaultCase(), - dependsOnColumn("debitorUuid"), - directlyFetchedByDependsOnColumn(), - NOT_NULL) - - .importEntityAlias("debitorRel", HsOfficeRelationEntity.class, usingCase(DEBITOR), - dependsOnColumn("debitorUuid"), - fetchedBySql(""" - SELECT ${columns} - FROM hs_office_relation debitorRel - JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid - WHERE debitor.uuid = ${REF}.debitorUuid - """), - NOT_NULL) - .toRole("debitorRel", ADMIN).grantPermission(INSERT) + .toRole("global", ADMIN).grantPermission(INSERT) // TODO.impl: Why is this necessary to insert test data? .toRole("global", ADMIN).grantPermission(DELETE) + .importEntityAlias("project", HsBookingProjectEntity.class, usingDefaultCase(), + dependsOnColumn("projectUuid"), + directlyFetchedByDependsOnColumn(), + NULLABLE) + .toRole("project", ADMIN).grantPermission(INSERT) + + .importEntityAlias("parentItem", HsBookingItemEntity.class, usingDefaultCase(), + dependsOnColumn("parentItemUuid"), + directlyFetchedByDependsOnColumn(), + NULLABLE) + .toRole("parentItem", ADMIN).grantPermission(INSERT) + .createRole(OWNER, (with) -> { - with.incomingSuperRole("debitorRel", AGENT); + with.incomingSuperRole("project", AGENT); + with.incomingSuperRole("parentItem", AGENT); }) .createSubRole(ADMIN, (with) -> { - with.incomingSuperRole("debitorRel", AGENT); with.permission(UPDATE); }) .createSubRole(AGENT) .createSubRole(TENANT, (with) -> { - with.outgoingSubRole("debitorRel", TENANT); + with.outgoingSubRole("project", TENANT); + with.outgoingSubRole("parentItem", TENANT); with.permission(SELECT); }) - .limitDiagramTo("bookingItem", "debitorRel", "global"); + .limitDiagramTo("bookingItem", "project", "global"); } public static void main(String[] args) throws IOException { - rbac().generateWithBaseFileName("6-hs-booking/601-booking-item/6013-hs-booking-item-rbac"); + rbac().generateWithBaseFileName("6-hs-booking/630-booking-item/6303-hs-booking-item-rbac"); } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepository.java index 6d9bd683..9ee9badc 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepository.java @@ -8,10 +8,11 @@ import java.util.UUID; public interface HsBookingItemRepository extends Repository { - List findAll(); Optional findByUuid(final UUID bookingItemUuid); - List findAllByDebitorUuid(final UUID bookingItemUuid); + List findByCaption(String bookingItemCaption); + + List findAllByProjectUuid(final UUID projectItemUuid); HsBookingItemEntity save(HsBookingItemEntity current); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemType.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemType.java index 719ce75b..8b51def8 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemType.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemType.java @@ -1,8 +1,35 @@ package net.hostsharing.hsadminng.hs.booking.item; -public enum HsBookingItemType { +import java.util.List; + +import static java.util.Optional.ofNullable; + +public enum HsBookingItemType implements Node { PRIVATE_CLOUD, - CLOUD_SERVER, - MANAGED_SERVER, - MANAGED_WEBSPACE + CLOUD_SERVER(PRIVATE_CLOUD), + MANAGED_SERVER(PRIVATE_CLOUD), + MANAGED_WEBSPACE(MANAGED_SERVER); + + private final HsBookingItemType parentItemType; + + HsBookingItemType() { + this.parentItemType = null; + } + + HsBookingItemType(final HsBookingItemType parentItemType) { + this.parentItemType = parentItemType; + } + + @Override + public List edges() { + return ofNullable(parentItemType) + .map(p -> (nodeName() + " *--> " + p.nodeName())) + .stream().toList(); + } + + @Override + public String nodeName() { + return "BI_" + name(); + } + } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/Node.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/Node.java new file mode 100644 index 00000000..cca14f5a --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/Node.java @@ -0,0 +1,9 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import java.util.List; + +public interface Node { + + String nodeName(); + List edges(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidator.java new file mode 100644 index 00000000..82a20e54 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidator.java @@ -0,0 +1,84 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import net.hostsharing.hsadminng.hs.validation.ValidatableProperty; +import org.apache.commons.lang3.BooleanUtils; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +import static java.util.Arrays.stream; +import static java.util.Collections.emptyList; +import static java.util.Optional.ofNullable; + +public class HsBookingItemEntityValidator extends HsEntityValidator { + + public HsBookingItemEntityValidator(final ValidatableProperty... properties) { + super(properties); + } + + @Override + public List validateEntity(final HsBookingItemEntity bookingItem) { + return enrich(prefix(bookingItem.toShortString(), "resources"), super.validateProperties(bookingItem)); + } + + @Override + public List validateContext(final HsBookingItemEntity bookingItem) { + return sequentiallyValidate( + () -> optionallyValidate(bookingItem.getParentItem()), + () -> validateAgainstSubEntities(bookingItem) + ); + } + + private static List optionallyValidate(final HsBookingItemEntity bookingItem) { + return bookingItem != null + ? enrich(prefix(bookingItem.toShortString(), ""), + HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateContext(bookingItem)) + : emptyList(); + } + + protected List validateAgainstSubEntities(final HsBookingItemEntity bookingItem) { + return enrich(prefix(bookingItem.toShortString(), "resources"), + Stream.concat( + stream(propertyValidators) + .map(propDef -> propDef.validateTotals(bookingItem)) + .flatMap(Collection::stream), + stream(propertyValidators) + .filter(ValidatableProperty::isTotalsValidator) + .map(prop -> validateMaxTotalValue(bookingItem, prop)) + ).filter(Objects::nonNull).toList()); + } + + // TODO.refa: convert into generic shape like multi-options validator + private static String validateMaxTotalValue( + final HsBookingItemEntity bookingItem, + final ValidatableProperty propDef) { + final var propName = propDef.propertyName(); + final var propUnit = ofNullable(propDef.unit()).map(u -> " " + u).orElse(""); + final var totalValue = ofNullable(bookingItem.getSubBookingItems()).orElse(emptyList()) + .stream() + .map(subItem -> propDef.getValue(subItem.getResources())) + .map(HsBookingItemEntityValidator::convertBooleanToInteger) + .map(HsBookingItemEntityValidator::toIntegerWithDefault0) + .reduce(0, Integer::sum); + final var maxValue = getIntegerValueWithDefault0(propDef, bookingItem.getResources()); + if (propDef.thresholdPercentage() != null ) { + return totalValue > (maxValue * propDef.thresholdPercentage() / 100) + ? "%s' maximum total is %d%s, but actual total %s is %d%s, which exceeds threshold of %d%%" + .formatted(propName, maxValue, propUnit, propName, totalValue, propUnit, propDef.thresholdPercentage()) + : null; + } else { + return totalValue > maxValue + ? "%s' maximum total is %d%s, but actual total %s is %d%s" + .formatted(propName, maxValue, propUnit, propName, totalValue, propUnit) + : null; + } + } + + private static Object convertBooleanToInteger(final Object value) { + return value instanceof Boolean ? BooleanUtils.toInteger((Boolean)value) : value; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidators.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorRegistry.java similarity index 52% rename from src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidators.java rename to src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorRegistry.java index 1f4493e2..388855ff 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidators.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorRegistry.java @@ -1,12 +1,12 @@ package net.hostsharing.hsadminng.hs.booking.item.validators; -import lombok.experimental.UtilityClass; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import net.hostsharing.hsadminng.errors.MultiValidationException; -import jakarta.validation.ValidationException; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Set; @@ -14,37 +14,44 @@ import static java.util.Arrays.stream; import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.CLOUD_SERVER; import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_SERVER; import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.PRIVATE_CLOUD; -@UtilityClass -public class HsBookingItemEntityValidators { +public class HsBookingItemEntityValidatorRegistry { - private static final Map, HsEntityValidator> validators = new HashMap<>(); + private static final Map, HsEntityValidator> validators = new HashMap<>(); static { + register(PRIVATE_CLOUD, new HsPrivateCloudBookingItemValidator()); register(CLOUD_SERVER, new HsCloudServerBookingItemValidator()); register(MANAGED_SERVER, new HsManagedServerBookingItemValidator()); register(MANAGED_WEBSPACE, new HsManagedWebspaceBookingItemValidator()); } - private static void register(final Enum type, final HsEntityValidator validator) { + private static void register(final Enum type, final HsEntityValidator validator) { stream(validator.propertyValidators).forEach( entry -> { entry.verifyConsistency(Map.entry(type, validator)); }); validators.put(type, validator); } - public static HsEntityValidator forType(final Enum type) { - return validators.get(type); + public static HsEntityValidator forType(final Enum type) { + if ( validators.containsKey(type)) { + return validators.get(type); + } + throw new IllegalArgumentException("no validator found for type " + type); } public static Set> types() { return validators.keySet(); } - public static HsBookingItemEntity valid(final HsBookingItemEntity entityToSave) { - final var violations = HsBookingItemEntityValidators.forType(entityToSave.getType()).validate(entityToSave); - if (!violations.isEmpty()) { - throw new ValidationException(violations.toString()); - } + public static List doValidate(final HsBookingItemEntity bookingItem) { + return HsEntityValidator.sequentiallyValidate( + () -> HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateEntity(bookingItem), + () -> HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateContext(bookingItem)); + } + + public static HsBookingItemEntity validated(final HsBookingItemEntity entityToSave) { + MultiValidationException.throwIfNotEmpty(doValidate(entityToSave)); return entityToSave; } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidator.java index fa09f2c3..d673f01a 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidator.java @@ -1,22 +1,28 @@ package net.hostsharing.hsadminng.hs.booking.item.validators; -import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; -import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; -import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; -import static net.hostsharing.hsadminng.hs.validation.EnumerationPropertyValidator.enumerationProperty; -import static net.hostsharing.hsadminng.hs.validation.IntegerPropertyValidator.integerProperty; - -class HsCloudServerBookingItemValidator extends HsEntityValidator { +class HsCloudServerBookingItemValidator extends HsBookingItemEntityValidator { HsCloudServerBookingItemValidator() { super( - integerProperty("CPUs").min(1).max(32).required(), - integerProperty("RAM").unit("GB").min(1).max(128).required(), - integerProperty("SSD").unit("GB").min(25).max(1000).step(25).required(), - integerProperty("HDD").unit("GB").min(0).max(4000).step(250).optional(), - integerProperty("Traffic").unit("GB").min(250).max(10000).step(250).required(), + // @formatter:off + booleanProperty("active") .withDefault(true), + + integerProperty("CPUs") .min( 1) .max( 32) .required(), + integerProperty("RAM").unit("GB") .min( 1) .max( 128) .required(), + integerProperty("SSD").unit("GB") .min( 0) .max( 1000) .step(25).required(), // (1) + integerProperty("HDD").unit("GB") .min( 0) .max( 4000) .step(250).withDefault(0), + integerProperty("Traffic").unit("GB") .min(250) .max(10000) .step(250).required(), + enumerationProperty("SLA-Infrastructure").values("BASIC", "EXT8H", "EXT4H", "EXT2H").optional() + // @formatter:on ); + + // (q) We do have pre-existing CloudServers without SSD, just HDD, thus SSD starts with min=0. + // TODO.impl: Validation that SSD+HDD is at minimum 25 GB is missing. + // e.g. validationGroup("SSD", "HDD").min(0); } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedServerBookingItemValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedServerBookingItemValidator.java index 79c41070..a267b104 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedServerBookingItemValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedServerBookingItemValidator.java @@ -1,24 +1,22 @@ package net.hostsharing.hsadminng.hs.booking.item.validators; -import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; -import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; -import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; -import static net.hostsharing.hsadminng.hs.validation.BooleanPropertyValidator.booleanProperty; -import static net.hostsharing.hsadminng.hs.validation.EnumerationPropertyValidator.enumerationProperty; -import static net.hostsharing.hsadminng.hs.validation.IntegerPropertyValidator.integerProperty; -class HsManagedServerBookingItemValidator extends HsEntityValidator { +import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; + +class HsManagedServerBookingItemValidator extends HsBookingItemEntityValidator { HsManagedServerBookingItemValidator() { super( integerProperty("CPUs").min(1).max(32).required(), integerProperty("RAM").unit("GB").min(1).max(128).required(), - integerProperty("SSD").unit("GB").min(25).max(1000).step(25).required(), - integerProperty("HDD").unit("GB").min(0).max(4000).step(250).optional(), - integerProperty("Traffic").unit("GB").min(250).max(10000).step(250).required(), - enumerationProperty("SLA-Platform").values("BASIC", "EXT8H", "EXT4H", "EXT2H").optional(), - booleanProperty("SLA-EMail").falseIf("SLA-Platform", "BASIC").optional(), + integerProperty("SSD").unit("GB").min(25).max(1000).step(25).required().asTotalLimit().withThreshold(200), + integerProperty("HDD").unit("GB").min(0).max(4000).step(250).withDefault(0).asTotalLimit().withThreshold(200), + integerProperty("Traffic").unit("GB").min(250).max(10000).step(250).required().asTotalLimit().withThreshold(200), + enumerationProperty("SLA-Platform").values("BASIC", "EXT8H", "EXT4H", "EXT2H").withDefault("BASIC"), + booleanProperty("SLA-EMail").falseIf("SLA-Platform", "BASIC").withDefault(false), booleanProperty("SLA-Maria").falseIf("SLA-Platform", "BASIC").optional(), booleanProperty("SLA-PgSQL").falseIf("SLA-Platform", "BASIC").optional(), booleanProperty("SLA-Office").falseIf("SLA-Platform", "BASIC").optional(), diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidator.java index 482d0900..1f094f36 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidator.java @@ -1,24 +1,103 @@ package net.hostsharing.hsadminng.hs.booking.item.validators; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; -import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; -import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import net.hostsharing.hsadminng.hs.validation.IntegerProperty; +import org.apache.commons.lang3.function.TriFunction; +import java.util.List; -import static net.hostsharing.hsadminng.hs.validation.BooleanPropertyValidator.booleanProperty; -import static net.hostsharing.hsadminng.hs.validation.EnumerationPropertyValidator.enumerationProperty; -import static net.hostsharing.hsadminng.hs.validation.IntegerPropertyValidator.integerProperty; +import static java.util.Collections.emptyList; +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_MBOX_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.EMAIL_ADDRESS; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_DATABASE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MARIADB_USER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_DATABASE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.PGSQL_USER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER; +import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; -class HsManagedWebspaceBookingItemValidator extends HsEntityValidator { +class HsManagedWebspaceBookingItemValidator extends HsBookingItemEntityValidator { public HsManagedWebspaceBookingItemValidator() { super( integerProperty("SSD").unit("GB").min(1).max(100).step(1).required(), integerProperty("HDD").unit("GB").min(0).max(250).step(10).optional(), integerProperty("Traffic").unit("GB").min(10).max(1000).step(10).required(), - enumerationProperty("SLA-Platform").values("BASIC", "EXT24H").optional(), - integerProperty("Daemons").min(0).max(10).optional(), - booleanProperty("Online Office Server").optional() + integerProperty("Multi").min(1).max(100).step(1).withDefault(1) + .eachComprising( 25, unixUsers()) + .eachComprising( 5, databaseUsers()) + .eachComprising( 5, databases()) + .eachComprising(250, eMailAddresses()), + integerProperty("Daemons").min(0).max(10).withDefault(0), + booleanProperty("Online Office Server").optional(), + enumerationProperty("SLA-Platform").values("BASIC", "EXT24H").withDefault("BASIC") ); } + + private static TriFunction> unixUsers() { + return (final HsBookingItemEntity entity, final IntegerProperty prop, final Integer factor) -> { + final var unixUserCount = ofNullable(entity.getRelatedHostingAsset()) + .map(ha -> ha.getSubHostingAssets().stream() + .filter(subAsset -> subAsset.getType() == UNIX_USER) + .count()) + .orElse(0L); + final long limitingValue = prop.getValue(entity.getResources()); + if (unixUserCount > factor*limitingValue) { + return List.of(prop.propertyName() + "=" + limitingValue + " allows at maximum " + limitingValue*factor + " unix users, but " + unixUserCount + " found"); + } + return emptyList(); + }; + } + + private static TriFunction> databaseUsers() { + return (final HsBookingItemEntity entity, final IntegerProperty prop, final Integer factor) -> { + final var dbUserCount = ofNullable(entity.getRelatedHostingAsset()) + .map(ha -> ha.getSubHostingAssets().stream() + .filter(bi -> bi.getType() == PGSQL_USER || bi.getType() == MARIADB_USER ) + .count()) + .orElse(0L); + final long limitingValue = prop.getValue(entity.getResources()); + if (dbUserCount > factor*limitingValue) { + return List.of(prop.propertyName() + "=" + limitingValue + " allows at maximum " + limitingValue*factor + " database users, but " + dbUserCount + " found"); + } + return emptyList(); + }; + } + + private static TriFunction> databases() { + return (final HsBookingItemEntity entity, final IntegerProperty prop, final Integer factor) -> { + final var unixUserCount = ofNullable(entity.getRelatedHostingAsset()) + .map(ha -> ha.getSubHostingAssets().stream() + .filter(bi -> bi.getType()==PGSQL_USER || bi.getType()==MARIADB_USER ) + .flatMap(domainEMailSetup -> domainEMailSetup.getSubHostingAssets().stream() + .filter(subAsset -> subAsset.getType()==PGSQL_DATABASE || subAsset.getType()==MARIADB_DATABASE)) + .count()) + .orElse(0L); + final long limitingValue = prop.getValue(entity.getResources()); + if (unixUserCount > factor*limitingValue) { + return List.of(prop.propertyName() + "=" + limitingValue + " allows at maximum " + limitingValue*factor + " databases, but " + unixUserCount + " found"); + } + return emptyList(); + }; + } + + private static TriFunction> eMailAddresses() { + return (final HsBookingItemEntity entity, final IntegerProperty prop, final Integer factor) -> { + final var unixUserCount = ofNullable(entity.getRelatedHostingAsset()) + .map(ha -> ha.getSubHostingAssets().stream() + .filter(bi -> bi.getType() == DOMAIN_MBOX_SETUP) + .flatMap(domainEMailSetup -> domainEMailSetup.getSubHostingAssets().stream() + .filter(subAsset -> subAsset.getType()==EMAIL_ADDRESS)) + .count()) + .orElse(0L); + final long limitingValue = prop.getValue(entity.getResources()); + if (unixUserCount > factor*limitingValue) { + return List.of(prop.propertyName() + "=" + limitingValue + " allows at maximum " + limitingValue*factor + " databases, but " + unixUserCount + " found"); + } + return emptyList(); + }; + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsPrivateCloudBookingItemValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsPrivateCloudBookingItemValidator.java new file mode 100644 index 00000000..236a000a --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsPrivateCloudBookingItemValidator.java @@ -0,0 +1,40 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; + +class HsPrivateCloudBookingItemValidator extends HsBookingItemEntityValidator { + + HsPrivateCloudBookingItemValidator() { + super( + // @formatter:off + integerProperty("CPUs") .min( 1).max( 128).required().asTotalLimit(), + integerProperty("RAM").unit("GB") .min( 1).max( 512).required().asTotalLimit(), + integerProperty("SSD").unit("GB") .min( 25).max( 4000).step(25).required().asTotalLimit(), + integerProperty("HDD").unit("GB") .min( 0).max(16000).step(250).withDefault(0).asTotalLimit(), + integerProperty("Traffic").unit("GB") .min(250).max(40000).step(250).required().asTotalLimit(), + +// Alternatively we could specify it similarly to "Multi" option but exclusively counting: +// integerProperty("Resource-Points") .min(4).max(100).required() +// .each("CPUs").countsAs(64) +// .each("RAM").countsAs(64) +// .each("SSD").countsAs(18) +// .each("HDD").countsAs(2) +// .each("Traffic").countsAs(1), + + integerProperty("SLA-Infrastructure EXT8H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Infrastructure", "EXT8H"), + integerProperty("SLA-Infrastructure EXT4H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Infrastructure", "EXT4H"), + integerProperty("SLA-Infrastructure EXT2H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Infrastructure", "EXT2H"), + + integerProperty("SLA-Platform EXT8H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Platform", "EXT8H"), + integerProperty("SLA-Platform EXT4H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Platform", "EXT4H"), + integerProperty("SLA-Platform EXT2H") .min( 0).max( 20).withDefault(0).asTotalLimitFor("SLA-Platform", "EXT2H"), + + integerProperty("SLA-EMail") .min( 0).max( 20).withDefault(0).asTotalLimit(), + integerProperty("SLA-Maria") .min( 0).max( 20).withDefault(0).asTotalLimit(), + integerProperty("SLA-PgSQL") .min( 0).max( 20).withDefault(0).asTotalLimit(), + integerProperty("SLA-Office") .min( 0).max( 20).withDefault(0).asTotalLimit(), + integerProperty("SLA-Web") .min( 0).max( 20).withDefault(0).asTotalLimit() + // @formatter:on + ); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java new file mode 100644 index 00000000..1b614dee --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectController.java @@ -0,0 +1,128 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorRepository; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingProjectsApi; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectInsertResource; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectPatchResource; +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectResource; +import net.hostsharing.hsadminng.mapper.Mapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; + +import jakarta.persistence.EntityNotFoundException; +import java.util.List; +import java.util.UUID; +import java.util.function.BiConsumer; + +@RestController +public class HsBookingProjectController implements HsBookingProjectsApi { + + @Autowired + private Context context; + + @Autowired + private Mapper mapper; + + @Autowired + private HsBookingProjectRepository bookingProjectRepo; + + @Autowired + private HsBookingDebitorRepository debitorRepo; + + @Override + @Transactional(readOnly = true) + public ResponseEntity> listBookingProjectsByDebitorUuid( + final String currentUser, + final String assumedRoles, + final UUID debitorUuid) { + context.define(currentUser, assumedRoles); + + final var entities = bookingProjectRepo.findAllByDebitorUuid(debitorUuid); + + final var resources = mapper.mapList(entities, HsBookingProjectResource.class); + return ResponseEntity.ok(resources); + } + + @Override + @Transactional + public ResponseEntity addBookingProject( + final String currentUser, + final String assumedRoles, + final HsBookingProjectInsertResource body) { + + context.define(currentUser, assumedRoles); + + final var entityToSave = mapper.map(body, HsBookingProjectEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); + + final var saved = bookingProjectRepo.save(entityToSave); + + final var uri = + MvcUriComponentsBuilder.fromController(getClass()) + .path("/api/hs/booking/projects/{id}") + .buildAndExpand(saved.getUuid()) + .toUri(); + final var mapped = mapper.map(saved, HsBookingProjectResource.class); + return ResponseEntity.created(uri).body(mapped); + } + + @Override + @Transactional(readOnly = true) + public ResponseEntity getBookingProjectByUuid( + final String currentUser, + final String assumedRoles, + final UUID bookingProjectUuid) { + + context.define(currentUser, assumedRoles); + + final var result = bookingProjectRepo.findByUuid(bookingProjectUuid); + return result + .map(bookingProjectEntity -> ResponseEntity.ok( + mapper.map(bookingProjectEntity, HsBookingProjectResource.class))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @Override + @Transactional + public ResponseEntity deleteBookingIemByUuid( + final String currentUser, + final String assumedRoles, + final UUID bookingProjectUuid) { + context.define(currentUser, assumedRoles); + + final var result = bookingProjectRepo.deleteByUuid(bookingProjectUuid); + return result == 0 + ? ResponseEntity.notFound().build() + : ResponseEntity.noContent().build(); + } + + @Override + @Transactional + public ResponseEntity patchBookingProject( + final String currentUser, + final String assumedRoles, + final UUID bookingProjectUuid, + final HsBookingProjectPatchResource body) { + + context.define(currentUser, assumedRoles); + + final var current = bookingProjectRepo.findByUuid(bookingProjectUuid).orElseThrow(); + + new HsBookingProjectEntityPatcher(current).apply(body); + + final var saved = bookingProjectRepo.save(current); + final var mapped = mapper.map(saved, HsBookingProjectResource.class); + return ResponseEntity.ok(mapped); + } + + final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { + if (resource.getDebitorUuid() != null) { + entity.setDebitor(debitorRepo.findByUuid(resource.getDebitorUuid()) + .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] debitorUuid %s not found".formatted( + resource.getDebitorUuid())))); + } + }; +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntity.java new file mode 100644 index 00000000..b1cf4a41 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntity.java @@ -0,0 +1,114 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import lombok.*; +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; +import net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationEntity; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; +import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; +import net.hostsharing.hsadminng.rbac.rbacobject.RbacObject; +import net.hostsharing.hsadminng.stringify.Stringify; +import net.hostsharing.hsadminng.stringify.Stringifyable; + +import jakarta.persistence.*; +import java.io.IOException; +import java.util.UUID; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.office.relation.HsOfficeRelationType.DEBITOR; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NOT_NULL; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.*; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.fetchedBySql; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; +import static net.hostsharing.hsadminng.stringify.Stringify.stringify; + +@Builder +@Entity +@Table(name = "hs_booking_project_rv") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class HsBookingProjectEntity implements Stringifyable, RbacObject { + + private static Stringify stringify = stringify(HsBookingProjectEntity.class) + .withProp(HsBookingProjectEntity::getDebitor) + .withProp(HsBookingProjectEntity::getCaption) + .quotedValues(false); + + @Id + @GeneratedValue + private UUID uuid; + + @Version + private int version; + + @ManyToOne(optional = false) + @JoinColumn(name = "debitoruuid") + private HsBookingDebitorEntity debitor; + + @Column(name = "caption") + private String caption; + + @Override + public String toString() { + return stringify.apply(this); + } + + @Override + public String toShortString() { + return ofNullable(debitor).map(HsBookingDebitorEntity::toShortString).orElse("D-???????") + + ":" + caption; + } + + public static RbacView rbac() { + return rbacViewFor("project", HsBookingProjectEntity.class) + .withIdentityView(SQL.query(""" + SELECT bookingProject.uuid as uuid, debitorIV.idName || '-' || cleanIdentifier(bookingProject.caption) as idName + FROM hs_booking_project bookingProject + JOIN hs_office_debitor_iv debitorIV ON debitorIV.uuid = bookingProject.debitorUuid + """)) + .withRestrictedViewOrderBy(SQL.expression("caption")) + .withUpdatableColumns("version", "caption") + + .importEntityAlias("debitor", HsOfficeDebitorEntity.class, usingDefaultCase(), + dependsOnColumn("debitorUuid"), + directlyFetchedByDependsOnColumn(), + NOT_NULL) + + .importEntityAlias("debitorRel", HsOfficeRelationEntity.class, usingCase(DEBITOR), + dependsOnColumn("debitorUuid"), + fetchedBySql(""" + SELECT ${columns} + FROM hs_office_relation debitorRel + JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid + WHERE debitor.uuid = ${REF}.debitorUuid + """), + NOT_NULL) + .toRole("debitorRel", ADMIN).grantPermission(INSERT) + .toRole("global", ADMIN).grantPermission(DELETE) + + .createRole(OWNER, (with) -> { + with.incomingSuperRole("debitorRel", AGENT); + }) + .createSubRole(ADMIN, (with) -> { + with.permission(UPDATE); + }) + .createSubRole(AGENT) + .createSubRole(TENANT, (with) -> { + with.outgoingSubRole("debitorRel", TENANT); + with.permission(SELECT); + }) + + .limitDiagramTo("project", "debitorRel", "global"); + } + + public static void main(String[] args) throws IOException { + rbac().generateWithBaseFileName("6-hs-booking/620-booking-project/6203-hs-booking-project-rbac"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityPatcher.java new file mode 100644 index 00000000..239fb075 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityPatcher.java @@ -0,0 +1,22 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectPatchResource; +import net.hostsharing.hsadminng.mapper.EntityPatcher; +import net.hostsharing.hsadminng.mapper.OptionalFromJson; + + + +public class HsBookingProjectEntityPatcher implements EntityPatcher { + + private final HsBookingProjectEntity entity; + + public HsBookingProjectEntityPatcher(final HsBookingProjectEntity entity) { + this.entity = entity; + } + + @Override + public void apply(final HsBookingProjectPatchResource resource) { + OptionalFromJson.of(resource.getCaption()) + .ifPresent(entity::setCaption); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepository.java new file mode 100644 index 00000000..f8a171b4 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepository.java @@ -0,0 +1,21 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import org.springframework.data.repository.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface HsBookingProjectRepository extends Repository { + + Optional findByUuid(final UUID bookingProjectUuid); + List findByCaption(final String projectCaption); + + List findAllByDebitorUuid(final UUID bookingProjectUuid); + + HsBookingProjectEntity save(HsBookingProjectEntity current); + + int deleteByUuid(final UUID uuid); + + long count(); +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java index 57e91ec5..3ca7efff 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetController.java @@ -1,5 +1,8 @@ package net.hostsharing.hsadminng.hs.hosting.asset; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRepository; +import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntitySaveProcessor; +import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry; import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetsApi; import net.hostsharing.hsadminng.context.Context; @@ -15,16 +18,20 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; +import jakarta.persistence.EntityManager; import jakarta.persistence.EntityNotFoundException; +import jakarta.persistence.PersistenceContext; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.function.BiConsumer; -import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsHostingAssetEntityValidators.valid; - @RestController public class HsHostingAssetController implements HsHostingAssetsApi { + @PersistenceContext + private EntityManager em; + @Autowired private Context context; @@ -34,6 +41,9 @@ public class HsHostingAssetController implements HsHostingAssetsApi { @Autowired private HsHostingAssetRepository assetRepo; + @Autowired + private HsBookingItemRepository bookingItemRepo; + @Override @Transactional(readOnly = true) public ResponseEntity> listAssets( @@ -46,7 +56,7 @@ public class HsHostingAssetController implements HsHostingAssetsApi { final var entities = assetRepo.findAllByCriteria(debitorUuid, parentAssetUuid, HsHostingAssetType.of(type)); - final var resources = mapper.mapList(entities, HsHostingAssetResource.class); + final var resources = mapper.mapList(entities, HsHostingAssetResource.class, ENTITY_TO_RESOURCE_POSTMAPPER); return ResponseEntity.ok(resources); } @@ -60,16 +70,22 @@ public class HsHostingAssetController implements HsHostingAssetsApi { context.define(currentUser, assumedRoles); - final var entityToSave = mapper.map(body, HsHostingAssetEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); + final var entity = mapper.map(body, HsHostingAssetEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER); - final var saved = assetRepo.save(valid(entityToSave)); + final var mapped = new HostingAssetEntitySaveProcessor(entity) + .preprocessEntity() + .validateEntity() + .prepareForSave() + .saveUsing(assetRepo::save) + .validateContext() + .mapUsing(e -> mapper.map(e, HsHostingAssetResource.class)) + .revampProperties(); final var uri = MvcUriComponentsBuilder.fromController(getClass()) .path("/api/hs/hosting/assets/{id}") - .buildAndExpand(saved.getUuid()) + .buildAndExpand(mapped.getUuid()) .toUri(); - final var mapped = mapper.map(saved, HsHostingAssetResource.class); return ResponseEntity.created(uri).body(mapped); } @@ -78,14 +94,14 @@ public class HsHostingAssetController implements HsHostingAssetsApi { public ResponseEntity getAssetByUuid( final String currentUser, final String assumedRoles, - final UUID serverUuid) { + final UUID assetUuid) { context.define(currentUser, assumedRoles); - final var result = assetRepo.findByUuid(serverUuid); + final var result = assetRepo.findByUuid(assetUuid); return result - .map(serverEntity -> ResponseEntity.ok( - mapper.map(serverEntity, HsHostingAssetResource.class))) + .map(assetEntity -> ResponseEntity.ok( + mapper.map(assetEntity, HsHostingAssetResource.class, ENTITY_TO_RESOURCE_POSTMAPPER))) .orElseGet(() -> ResponseEntity.notFound().build()); } @@ -94,10 +110,10 @@ public class HsHostingAssetController implements HsHostingAssetsApi { public ResponseEntity deleteAssetUuid( final String currentUser, final String assumedRoles, - final UUID serverUuid) { + final UUID assetUuid) { context.define(currentUser, assumedRoles); - final var result = assetRepo.deleteByUuid(serverUuid); + final var result = assetRepo.deleteByUuid(assetUuid); return result == 0 ? ResponseEntity.notFound().build() : ResponseEntity.noContent().build(); @@ -108,26 +124,43 @@ public class HsHostingAssetController implements HsHostingAssetsApi { public ResponseEntity patchAsset( final String currentUser, final String assumedRoles, - final UUID serverUuid, + final UUID assetUuid, final HsHostingAssetPatchResource body) { context.define(currentUser, assumedRoles); - final var current = assetRepo.findByUuid(serverUuid).orElseThrow(); + final var entity = assetRepo.findByUuid(assetUuid).orElseThrow(); - new HsHostingAssetEntityPatcher(current).apply(body); + new HsHostingAssetEntityPatcher(em, entity).apply(body); + + final var mapped = new HostingAssetEntitySaveProcessor(entity) + .preprocessEntity() + .validateEntity() + .prepareForSave() + .saveUsing(assetRepo::save) + .validateContext() + .mapUsing(e -> mapper.map(e, HsHostingAssetResource.class)) + .revampProperties(); - final var saved = assetRepo.save(valid(current)); - final var mapped = mapper.map(saved, HsHostingAssetResource.class); return ResponseEntity.ok(mapped); } final BiConsumer RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> { entity.putConfig(KeyValueMap.from(resource.getConfig())); + if (resource.getBookingItemUuid() != null) { + entity.setBookingItem(bookingItemRepo.findByUuid(resource.getBookingItemUuid()) + .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] bookingItemUuid %s not found".formatted( + resource.getBookingItemUuid())))); + } if (resource.getParentAssetUuid() != null) { entity.setParentAsset(assetRepo.findByUuid(resource.getParentAssetUuid()) .orElseThrow(() -> new EntityNotFoundException("ERROR: [400] parentAssetUuid %s not found".formatted( resource.getParentAssetUuid())))); } }; + + @SuppressWarnings("unchecked") + final BiConsumer ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) + -> resource.setConfig(HostingAssetEntityValidatorRegistry.forType(entity.getType()) + .revampProperties(entity, (Map) resource.getConfig())); } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntity.java index 1f4ec01a..55b8d00e 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntity.java @@ -8,7 +8,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; -import net.hostsharing.hsadminng.hs.validation.Validatable; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.validation.PropertiesProvider; import net.hostsharing.hsadminng.mapper.PatchableMapWrapper; import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; import net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL; @@ -17,38 +18,44 @@ import net.hostsharing.hsadminng.stringify.Stringify; import net.hostsharing.hsadminng.stringify.Stringifyable; import org.hibernate.annotations.Type; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.PostLoad; import jakarta.persistence.Table; import jakarta.persistence.Transient; import jakarta.persistence.Version; import java.io.IOException; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; -import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; -import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; -import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static java.util.Collections.emptyMap; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.CaseDef.inCaseOf; -import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.CaseDef.inOtherCases; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Column.dependsOnColumn; -import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingCase; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.ColumnValue.usingDefaultCase; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.GLOBAL; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Nullable.NULLABLE; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.DELETE; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.INSERT; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.SELECT; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Permission.UPDATE; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.RbacUserReference.UserRole.CREATOR; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.ADMIN; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.AGENT; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.GUEST; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.OWNER; +import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.REFERRER; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.Role.TENANT; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.SQL.directlyFetchedByDependsOnColumn; import static net.hostsharing.hsadminng.rbac.rbacdef.RbacView.rbacViewFor; @@ -61,13 +68,14 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify; @Setter @NoArgsConstructor @AllArgsConstructor -public class HsHostingAssetEntity implements Stringifyable, RbacObject, Validatable { +public class HsHostingAssetEntity implements Stringifyable, RbacObject, PropertiesProvider { private static Stringify stringify = stringify(HsHostingAssetEntity.class) .withProp(HsHostingAssetEntity::getType) .withProp(HsHostingAssetEntity::getIdentifier) .withProp(HsHostingAssetEntity::getCaption) .withProp(HsHostingAssetEntity::getParentAsset) + .withProp(HsHostingAssetEntity::getAssignedToAsset) .withProp(HsHostingAssetEntity::getBookingItem) .withProp(HsHostingAssetEntity::getConfig) .quotedValues(false); @@ -79,20 +87,32 @@ public class HsHostingAssetEntity implements Stringifyable, RbacObject, Validata @Version private int version; - @ManyToOne(optional = false) + @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "bookingitemuuid") private HsBookingItemEntity bookingItem; - @ManyToOne(optional = true) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parentassetuuid") private HsHostingAssetEntity parentAsset; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assignedtoassetuuid") + private HsHostingAssetEntity assignedToAsset; + @Column(name = "type") @Enumerated(EnumType.STRING) private HsHostingAssetType type; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "alarmcontactuuid") + private HsOfficeContactEntity alarmContact; + + @OneToMany(cascade = CascadeType.REFRESH, orphanRemoval = true, fetch = FetchType.LAZY) + @JoinColumn(name="parentassetuuid", referencedColumnName="uuid") + private List subHostingAssets; + @Column(name = "identifier") - private String identifier; // vm1234, xyz00, example.org, xyz00_abc + private String identifier; // e.g. vm1234, xyz00, example.org, xyz00_abc @Column(name = "caption") private String caption; @@ -106,24 +126,44 @@ public class HsHostingAssetEntity implements Stringifyable, RbacObject, Validata @Transient private PatchableMapWrapper configWrapper; + @Transient + private boolean isLoaded; + + @PostLoad + public void markAsLoaded() { + this.isLoaded = true; + } + public PatchableMapWrapper getConfig() { return PatchableMapWrapper.of(configWrapper, (newWrapper) -> {configWrapper = newWrapper; }, config ); } - public void putConfig(Map newConfg) { - PatchableMapWrapper.of(configWrapper, (newWrapper) -> {configWrapper = newWrapper; }, config).assign(newConfg); + public void putConfig(Map newConfig) { + PatchableMapWrapper.of(configWrapper, (newWrapper) -> {configWrapper = newWrapper; }, config).assign(newConfig); } @Override - public String getPropertiesName() { - return "config"; - } - - @Override - public Map getProperties() { + public Map directProps() { return config; } + @Override + public Object getContextValue(final String propName) { + final var v = config.get(propName); + if (v!= null) { + return v; + } + + if (bookingItem!=null) { + return bookingItem.getResources().get(propName); + } + if (parentAsset!=null && parentAsset.getBookingItem()!=null) { + return parentAsset.getBookingItem().getResources().get(propName); + } + return emptyMap(); + } + + @Override public String toString() { return stringify.apply(this); @@ -136,48 +176,62 @@ public class HsHostingAssetEntity implements Stringifyable, RbacObject, Validata public static RbacView rbac() { return rbacViewFor("asset", HsHostingAssetEntity.class) - .withIdentityView(SQL.query(""" - SELECT asset.uuid as uuid, bookingItemIV.idName || '-' || cleanIdentifier(asset.identifier) as idName - FROM hs_hosting_asset asset - JOIN hs_booking_item_iv bookingItemIV ON bookingItemIV.uuid = asset.bookingItemUuid - """)) + .withIdentityView(SQL.projection("identifier")) .withRestrictedViewOrderBy(SQL.expression("identifier")) - .withUpdatableColumns("version", "caption", "config") + .withUpdatableColumns("version", "caption", "config", "assignedToAssetUuid", "alarmContactUuid") + .toRole(GLOBAL, ADMIN).grantPermission(INSERT) // TODO.impl: Why is this necessary to insert test data? .importEntityAlias("bookingItem", HsBookingItemEntity.class, usingDefaultCase(), dependsOnColumn("bookingItemUuid"), directlyFetchedByDependsOnColumn(), NULLABLE) + .importEntityAlias("parentAsset", HsHostingAssetEntity.class, usingDefaultCase(), + dependsOnColumn("parentAssetUuid"), + directlyFetchedByDependsOnColumn(), + NULLABLE) + .toRole("parentAsset", ADMIN).grantPermission(INSERT) + + .importEntityAlias("assignedToAsset", HsHostingAssetEntity.class, usingDefaultCase(), + dependsOnColumn("assignedToAssetUuid"), + directlyFetchedByDependsOnColumn(), + NULLABLE) + + .importEntityAlias("alarmContact", HsOfficeContactEntity.class, usingDefaultCase(), + dependsOnColumn("alarmContactUuid"), + directlyFetchedByDependsOnColumn(), + NULLABLE) + .switchOnColumn("type", - inCaseOf(CLOUD_SERVER.name(), - then -> then.toRole("bookingItem", AGENT).grantPermission(INSERT)), - inCaseOf(MANAGED_SERVER.name(), - then -> then.toRole("bookingItem", AGENT).grantPermission(INSERT)), - inCaseOf(MANAGED_WEBSPACE.name(), then -> - then.importEntityAlias("parentServer", HsHostingAssetEntity.class, usingCase(MANAGED_SERVER), - dependsOnColumn("parentAssetUuid"), - directlyFetchedByDependsOnColumn(), - NULLABLE) - .toRole("parentServer", ADMIN).grantPermission(INSERT) - .toRole("bookingItem", AGENT).grantPermission(INSERT) - ), - inOtherCases(then -> {}) + inCaseOf("DOMAIN_SETUP", then -> { + then.toRole(GLOBAL, GUEST).grantPermission(INSERT); + }) ) .createRole(OWNER, (with) -> { + with.owningUser(CREATOR); + with.incomingSuperRole(GLOBAL, ADMIN).unassumed(); // TODO.spec: replace by a better solution with.incomingSuperRole("bookingItem", ADMIN); + with.incomingSuperRole("parentAsset", ADMIN); with.permission(DELETE); }) .createSubRole(ADMIN, (with) -> { + with.incomingSuperRole("bookingItem", AGENT); + with.incomingSuperRole("parentAsset", AGENT); with.permission(UPDATE); }) + .createSubRole(AGENT, (with) -> { + with.outgoingSubRole("assignedToAsset", TENANT); + with.outgoingSubRole("alarmContact", REFERRER); + }) .createSubRole(TENANT, (with) -> { with.outgoingSubRole("bookingItem", TENANT); + with.outgoingSubRole("parentAsset", TENANT); + with.incomingSuperRole("alarmContact", ADMIN); with.permission(SELECT); }) - .limitDiagramTo("asset", "bookingItem", "bookingItem.debitorRel", "parentServer", "global"); + .limitDiagramTo("asset", "bookingItem", "bookingItem.debitorRel", "parentAsset", "assignedToAsset", "alarmContact", "global"); } public static void main(String[] args) throws IOException { diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcher.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcher.java index a555be19..f1cff713 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcher.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcher.java @@ -1,17 +1,21 @@ package net.hostsharing.hsadminng.hs.hosting.asset; import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetPatchResource; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; import net.hostsharing.hsadminng.mapper.EntityPatcher; import net.hostsharing.hsadminng.mapper.KeyValueMap; import net.hostsharing.hsadminng.mapper.OptionalFromJson; +import jakarta.persistence.EntityManager; import java.util.Optional; public class HsHostingAssetEntityPatcher implements EntityPatcher { + private final EntityManager em; private final HsHostingAssetEntity entity; - public HsHostingAssetEntityPatcher(final HsHostingAssetEntity entity) { + HsHostingAssetEntityPatcher(final EntityManager em, final HsHostingAssetEntity entity) { + this.em = em; this.entity = entity; } @@ -21,5 +25,11 @@ public class HsHostingAssetEntityPatcher implements EntityPatcher entity.getConfig().patch(KeyValueMap.from(resource.getConfig()))); + OptionalFromJson.of(resource.getAlarmContactUuid()) + // HOWTO: patch nullable JSON resource uuid to an ntity reference + .ifPresent(newValue -> entity.setAlarmContact( + Optional.ofNullable(newValue) + .map(uuid -> em.getReference(HsOfficeContactEntity.class, newValue)) + .orElse(null))); } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java index 47852310..ca8bbb08 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsController.java @@ -1,6 +1,6 @@ package net.hostsharing.hsadminng.hs.hosting.asset; -import net.hostsharing.hsadminng.hs.hosting.asset.validators.HsHostingAssetEntityValidators; +import net.hostsharing.hsadminng.hs.hosting.asset.validators.HostingAssetEntityValidatorRegistry; import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.api.HsHostingAssetPropsApi; import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetTypeResource; import org.springframework.http.ResponseEntity; @@ -15,7 +15,7 @@ public class HsHostingAssetPropsController implements HsHostingAssetPropsApi { @Override public ResponseEntity> listAssetTypes() { - final var resource = HsHostingAssetEntityValidators.types().stream() + final var resource = HostingAssetEntityValidatorRegistry.types().stream() .map(Enum::name) .toList(); return ResponseEntity.ok(resource); @@ -25,7 +25,8 @@ public class HsHostingAssetPropsController implements HsHostingAssetPropsApi { public ResponseEntity> listAssetTypeProps( final HsHostingAssetTypeResource assetType) { - final var propValidators = HsHostingAssetEntityValidators.forType(HsHostingAssetType.of(assetType)); + final Enum type = HsHostingAssetType.of(assetType); + final var propValidators = HostingAssetEntityValidatorRegistry.forType(type); final List> resource = propValidators.properties(); return ResponseEntity.ok(toListOfObjects(resource)); } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepository.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepository.java index 4926c673..571f484a 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepository.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepository.java @@ -10,18 +10,33 @@ import java.util.UUID; public interface HsHostingAssetRepository extends Repository { - List findAll(); Optional findByUuid(final UUID serverUuid); - @Query(""" - SELECT asset FROM HsHostingAssetEntity asset - WHERE (:debitorUuid IS NULL OR asset.bookingItem.debitor.uuid = :debitorUuid) - AND (:parentAssetUuid IS NULL OR asset.parentAsset.uuid = :parentAssetUuid) - AND (:type IS NULL OR :type = CAST(asset.type AS String)) - """) - List findAllByCriteriaImpl(UUID debitorUuid, UUID parentAssetUuid, String type); - default List findAllByCriteria(final UUID debitorUuid, final UUID parentAssetUuid, final HsHostingAssetType type) { - return findAllByCriteriaImpl(debitorUuid, parentAssetUuid, HsHostingAssetType.asString(type)); + List findByIdentifier(String assetIdentifier); + + @Query(value = """ + select ha.uuid, + ha.alarmcontactuuid, + ha.assignedtoassetuuid, + ha.bookingitemuuid, + ha.caption, + ha.config, + ha.identifier, + ha.parentassetuuid, + ha.type, + ha.version + from hs_hosting_asset_rv ha + left join hs_booking_item bi on bi.uuid = ha.bookingitemuuid + left join hs_hosting_asset pha on pha.uuid = ha.parentassetuuid + where (:projectUuid is null or bi.projectuuid=:projectUuid) + and (:parentAssetUuid is null or pha.uuid=:parentAssetUuid) + and (:type is null or :type=cast(ha.type as text)) + """, nativeQuery = true) + // The JPQL query did not generate "left join" but just "join". + // I also optimized the query by not using the _rv for hs_booking_item and hs_hosting_asset, only for hs_hosting_asset_rv. + List findAllByCriteriaImpl(UUID projectUuid, UUID parentAssetUuid, String type); + default List findAllByCriteria(final UUID projectUuid, final UUID parentAssetUuid, final HsHostingAssetType type) { + return findAllByCriteriaImpl(projectUuid, parentAssetUuid, HsHostingAssetType.asString(type)); } HsHostingAssetEntity save(HsHostingAssetEntity current); diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetType.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetType.java index f4040046..0054974b 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetType.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetType.java @@ -1,30 +1,205 @@ package net.hostsharing.hsadminng.hs.hosting.asset; +import lombok.AllArgsConstructor; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.booking.item.Node; -public enum HsHostingAssetType { - CLOUD_SERVER, // named e.g. vm1234 - MANAGED_SERVER, // named e.g. vm1234 - MANAGED_WEBSPACE(MANAGED_SERVER), // named eg. xyz00 - UNIX_USER(MANAGED_WEBSPACE), // named e.g. xyz00-abc - DOMAIN_SETUP(UNIX_USER), // named e.g. example.org +import javax.naming.NamingException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toSet; +import static net.hostsharing.hsadminng.hs.hosting.asset.EntityTypeRelation.*; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationPolicy.OPTIONAL; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationPolicy.REQUIRED; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationType.ASSIGNED_TO_ASSET; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationType.BOOKING_ITEM; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.RelationType.PARENT_ASSET; + +public enum HsHostingAssetType implements Node { + SAME_TYPE, // pseudo-type for recursive references + + CLOUD_SERVER( // named e.g. vm1234 + inGroup("Server"), + requires(HsBookingItemType.CLOUD_SERVER)), + + MANAGED_SERVER( // named e.g. vm1234 + inGroup("Server"), + requires(HsBookingItemType.MANAGED_SERVER)), + + MANAGED_WEBSPACE( // named eg. xyz00 + inGroup("Webspace"), + requires(HsBookingItemType.MANAGED_WEBSPACE), + optionalParent(MANAGED_SERVER)), + + UNIX_USER( // named e.g. xyz00-abc + inGroup("Webspace"), + requiredParent(MANAGED_WEBSPACE)), + + EMAIL_ALIAS( // named e.g. xyz00-abc + inGroup("Webspace"), + requiredParent(MANAGED_WEBSPACE)), + + DOMAIN_SETUP( // named e.g. example.org + inGroup("Domain"), + optionalParent(SAME_TYPE) + ), + + DOMAIN_DNS_SETUP( // named e.g. example.org + inGroup("Domain"), + requiredParent(DOMAIN_SETUP), + assignedTo(MANAGED_WEBSPACE)), + + DOMAIN_HTTP_SETUP( // named e.g. example.org + inGroup("Domain"), + requiredParent(DOMAIN_SETUP), + assignedTo(UNIX_USER)), + + DOMAIN_SMTP_SETUP( // named e.g. example.org + inGroup("Domain"), + requiredParent(DOMAIN_SETUP), + assignedTo(MANAGED_WEBSPACE)), + + DOMAIN_MBOX_SETUP( // named e.g. example.org + inGroup("Domain"), + requiredParent(DOMAIN_SETUP), + assignedTo(MANAGED_WEBSPACE)), // TODO.spec: SECURE_MX - EMAIL_ALIAS(MANAGED_WEBSPACE), // named e.g. xyz00-abc - EMAIL_ADDRESS(DOMAIN_SETUP), // named e.g. sample@example.org - PGSQL_USER(MANAGED_WEBSPACE), // named e.g. xyz00_abc - PGSQL_DATABASE(MANAGED_WEBSPACE), // named e.g. xyz00_abc, TODO.spec: or PGSQL_USER? - MARIADB_USER(MANAGED_WEBSPACE), // named e.g. xyz00_abc - MARIADB_DATABASE(MANAGED_WEBSPACE); // named e.g. xyz00_abc, TODO.spec: or MARIADB_USER? + EMAIL_ADDRESS( // named e.g. sample@example.org + inGroup("Domain"), + requiredParent(DOMAIN_MBOX_SETUP)), - public final HsHostingAssetType parentAssetType; + PGSQL_INSTANCE( // TODO.spec: identifier to be specified + inGroup("PostgreSQL"), + requiredParent(MANAGED_SERVER)), - HsHostingAssetType(final HsHostingAssetType parentAssetType) { - this.parentAssetType = parentAssetType; + PGSQL_USER( // named e.g. xyz00_abc + inGroup("PostgreSQL"), + requiredParent(PGSQL_INSTANCE), + assignedTo(MANAGED_WEBSPACE)), + + PGSQL_DATABASE( // named e.g. xyz00_abc + inGroup("PostgreSQL"), + requiredParent(MANAGED_WEBSPACE), // TODO.spec: or PGSQL_USER? + assignedTo(PGSQL_INSTANCE)), // TODO.spec: or swapping parent+assignedTo? + + MARIADB_INSTANCE( // TODO.spec: identifier to be specified + inGroup("MariaDB"), + requiredParent(MANAGED_SERVER)), // TODO.spec: or MANAGED_WEBSPACE? + + MARIADB_USER( // named e.g. xyz00_abc + inGroup("MariaDB"), + requiredParent(MARIADB_INSTANCE), + assignedTo(MANAGED_WEBSPACE)), + + MARIADB_DATABASE( // named e.g. xyz00_abc + inGroup("MariaDB"), + requiredParent(MANAGED_WEBSPACE), // TODO.spec: or MARIADB_USER? + assignedTo(MARIADB_INSTANCE)), // TODO.spec: or swapping parent+assignedTo? + + IP_NUMBER( + inGroup("Server"), + assignedTo(CLOUD_SERVER), + assignedTo(MANAGED_SERVER), + assignedTo(MANAGED_WEBSPACE) + ); + + private final String groupName; + private final EntityTypeRelation[] relations; + + HsHostingAssetType( + final String groupName, + final EntityTypeRelation... relations + ) { + this.groupName = groupName; + this.relations = relations; } HsHostingAssetType() { - this(null); + this.groupName = null; + this.relations = null; + } + + /// just syntactic sugar + private static String inGroup(final String groupName) { + return groupName; + } + + // TODO.refa: try to get rid of the following similar methods: + + public RelationPolicy bookingItemPolicy() { + return stream(relations) + .filter(r -> r.relationType == BOOKING_ITEM) + .map(r -> r.relationPolicy) + .reduce(HsHostingAssetType::onlyASingleElementExpectedException) + .orElse(RelationPolicy.FORBIDDEN); + } + + public HsBookingItemType bookingItemType() { + return stream(relations) + .filter(r -> r.relationType == BOOKING_ITEM) + .map(r -> HsBookingItemType.valueOf(r.relatedType(this).toString())) + .reduce(HsHostingAssetType::onlyASingleElementExpectedException) + .orElse(null); + } + + public RelationPolicy parentAssetPolicy() { + return stream(relations) + .filter(r -> r.relationType == PARENT_ASSET) + .map(r -> r.relationPolicy) + .reduce(HsHostingAssetType::onlyASingleElementExpectedException) + .orElse(RelationPolicy.FORBIDDEN); + } + + public HsHostingAssetType parentAssetType() { + return stream(relations) + .filter(r -> r.relationType == PARENT_ASSET) + .map(r -> HsHostingAssetType.valueOf(r.relatedType(this).toString())) + .reduce(HsHostingAssetType::onlyASingleElementExpectedException) + .orElse(null); + } + + public RelationPolicy assignedToAssetPolicy() { + return stream(relations) + .filter(r -> r.relationType == ASSIGNED_TO_ASSET) + .map(r -> r.relationPolicy) + .reduce(HsHostingAssetType::onlyASingleElementExpectedException) + .orElse(RelationPolicy.FORBIDDEN); + } + + public HsHostingAssetType assignedToAssetType() { + return stream(relations) + .filter(r -> r.relationType == ASSIGNED_TO_ASSET) + .map(r -> HsHostingAssetType.valueOf(r.relatedType(this).toString())) + .reduce(HsHostingAssetType::onlyASingleElementExpectedException) + .orElse(null); + } + + private static X onlyASingleElementExpectedException(Object a, Object b) { + throw new IllegalStateException("Only a single element expected to match criteria."); + } + + @Override + public List edges() { + return stream(relations) + .map(r -> nodeName() + r.edge + r.relatedType(this).nodeName()) + .toList(); + } + + @Override + public String nodeName() { + return "HA_" + name(); } public static > HsHostingAssetType of(final T value) { @@ -34,4 +209,148 @@ public enum HsHostingAssetType { static String asString(final HsHostingAssetType type) { return type == null ? null : type.name(); } + + private static String renderAsPlantUML(final String caption, final Set includedHostingGroups) { + final String bookingNodes = stream(HsBookingItemType.values()) + .map(t -> " entity " + t.nodeName()) + .collect(joining("\n")); + final String hostingGroups = includedHostingGroups.stream().sorted() + .map(HsHostingAssetType::generateGroup) + .collect(joining("\n")); + final String hostingAssetNodes = stream(HsHostingAssetType.values()) + .filter(t -> t.isInGroups(includedHostingGroups)) + .map(t -> "entity " + t.nodeName()) + .collect(joining("\n")); + final String bookingItemEdges = stream(HsBookingItemType.values()) + .map(HsBookingItemType::edges) + .flatMap(Collection::stream) + .collect(joining("\n")); + final String hostingAssetEdges = stream(HsHostingAssetType.values()) + .filter(t -> t.isInGroups(includedHostingGroups)) + .map(HsHostingAssetType::edges) + .flatMap(Collection::stream) + .collect(joining("\n")); + return """ + + ### %{caption} + + ```plantuml + @startuml + left to right direction + + package Booking #feb28c { + %{bookingNodes} + } + + package Hosting #feb28c{ + %{hostingGroups} + } + + %{bookingItemEdges} + + %{hostingAssetEdges} + + package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 + } + Booking -down[hidden]->Legend + ``` + """ + .replace("%{caption}", caption) + .replace("%{bookingNodes}", bookingNodes) + .replace("%{hostingGroups}", hostingGroups) + .replace("%{hostingAssetNodeStyles}", hostingAssetNodes) + .replace("%{bookingItemEdges}", bookingItemEdges) + .replace("%{hostingAssetEdges}", hostingAssetEdges); + } + + private boolean isInGroups(final Set assetGroups) { + return groupName != null && assetGroups.contains(groupName); + } + + private static String generateGroup(final String group) { + return " package " + group + " #99bcdb {\n" + + stream(HsHostingAssetType.values()) + .filter(t -> group.equals(t.groupName)) + .map(t -> " entity " + t.nodeName()) + .collect(joining("\n")) + + "\n }\n"; + } + + static String renderAsEmbeddedPlantUml() { + + final var markdown = new StringBuilder(""" + ## HostingAsset Type Structure + + """); + + // rendering all types in a single diagram is currently ignored + renderAsPlantUML("Domain", stream(HsHostingAssetType.values()) + .filter(t -> t.groupName != null) + .map(t -> t.groupName) + .collect(toSet())); + + markdown.append(renderAsPlantUML("Domain", Set.of("Domain", "Webspace", "Server"))) + .append(renderAsPlantUML("MariaDB", Set.of("MariaDB", "Webspace", "Server"))) + .append(renderAsPlantUML("PostgreSQL", Set.of("PostgreSQL", "Webspace", "Server"))); + + markdown.append(""" + + This code generated was by %{this}.main, do not amend manually. + """ + .replace("%{this}", HsHostingAssetType.class.getSimpleName())); + + return markdown.toString(); + } + + public static void main(final String[] args) throws IOException, NamingException { + Files.writeString( + Path.of("doc/hs-hosting-asset-type-structure.md"), + renderAsEmbeddedPlantUml(), + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + public enum RelationPolicy { + FORBIDDEN, OPTIONAL, REQUIRED + } + + public enum RelationType { + BOOKING_ITEM, + PARENT_ASSET, + ASSIGNED_TO_ASSET + } +} + +@AllArgsConstructor +class EntityTypeRelation { + + final HsHostingAssetType.RelationPolicy relationPolicy; + final HsHostingAssetType.RelationType relationType; + final Function getter; + private final T relatedType; + final String edge; + + public T relatedType(final HsHostingAssetType referringType) { + //noinspection unchecked + return relatedType == HsHostingAssetType.SAME_TYPE ? (T) referringType : relatedType; + } + + static EntityTypeRelation requires(final HsBookingItemType bookingItemType) { + return new EntityTypeRelation<>(REQUIRED, BOOKING_ITEM, HsHostingAssetEntity::getBookingItem, bookingItemType, " *==> "); + } + + static EntityTypeRelation optionalParent(final HsHostingAssetType hostingAssetType) { + return new EntityTypeRelation<>(OPTIONAL, PARENT_ASSET, HsHostingAssetEntity::getParentAsset, hostingAssetType, " o..> "); + } + + static EntityTypeRelation requiredParent(final HsHostingAssetType hostingAssetType) { + return new EntityTypeRelation<>(REQUIRED, PARENT_ASSET, HsHostingAssetEntity::getParentAsset, hostingAssetType, " *==> "); + } + + static EntityTypeRelation assignedTo(final HsHostingAssetType hostingAssetType) { + return new EntityTypeRelation<>(REQUIRED, ASSIGNED_TO_ASSET, HsHostingAssetEntity::getAssignedToAsset, hostingAssetType, " o..> "); + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntitySaveProcessor.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntitySaveProcessor.java new file mode 100644 index 00000000..189b3314 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntitySaveProcessor.java @@ -0,0 +1,86 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.errors.MultiValidationException; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; +import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetResource; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; + +import java.util.Map; +import java.util.function.Function; + +/** + * Wraps the steps of the pararation, validation, mapping and revamp around saving of a HsHostingAssetEntity into a readable API. + */ +public class HostingAssetEntitySaveProcessor { + + private final HsEntityValidator validator; + private String expectedStep = "preprocessEntity"; + private HsHostingAssetEntity entity; + private HsHostingAssetResource resource; + + public HostingAssetEntitySaveProcessor(final HsHostingAssetEntity entity) { + this.entity = entity; + this.validator = HostingAssetEntityValidatorRegistry.forType(entity.getType()); + } + + /// initial step allowing to set default values before any validations + public HostingAssetEntitySaveProcessor preprocessEntity() { + step("preprocessEntity", "validateEntity"); + validator.preprocessEntity(entity); + return this; + } + + /// validates the entity itself including its properties + public HostingAssetEntitySaveProcessor validateEntity() { + step("validateEntity", "prepareForSave"); + MultiValidationException.throwIfNotEmpty(validator.validateEntity(entity)); + return this; + } + + /// hashing passwords etc. + @SuppressWarnings("unchecked") + public HostingAssetEntitySaveProcessor prepareForSave() { + step("prepareForSave", "saveUsing"); + validator.prepareProperties(entity); + return this; + } + + public HostingAssetEntitySaveProcessor saveUsing(final Function saveFunction) { + step("saveUsing", "validateContext"); + entity = saveFunction.apply(entity); + return this; + } + + /// validates the entity within it's parent and child hierarchy (e.g. totals validators and other limits) + public HostingAssetEntitySaveProcessor validateContext() { + step("validateContext", "mapUsing"); + MultiValidationException.throwIfNotEmpty(validator.validateContext(entity)); + return this; + } + + /// maps entity to JSON resource representation + public HostingAssetEntitySaveProcessor mapUsing( + final Function mapFunction) { + step("mapUsing", "revampProperties"); + resource = mapFunction.apply(entity); + return this; + } + + /// removes write-only-properties and ads computed-properties + @SuppressWarnings("unchecked") + public HsHostingAssetResource revampProperties() { + step("revampProperties", null); + final var revampedProps = validator.revampProperties(entity, (Map) resource.getConfig()); + resource.setConfig(revampedProps); + return resource; + } + + // Makes sure that the steps are called in the correct order. + // Could also be implemented using an interface per method, but that seems exaggerated. + private void step(final String current, final String next) { + if (!expectedStep.equals(current)) { + throw new IllegalStateException("expected " + expectedStep + " but got " + current); + } + expectedStep = next; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidator.java new file mode 100644 index 00000000..0c0282e0 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidator.java @@ -0,0 +1,221 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidatorRegistry; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; +import net.hostsharing.hsadminng.hs.validation.ValidatableProperty; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static java.util.Arrays.stream; +import static java.util.Collections.emptyList; +import static java.util.Optional.ofNullable; + +public abstract class HostingAssetEntityValidator extends HsEntityValidator { + + static final ValidatableProperty[] NO_EXTRA_PROPERTIES = new ValidatableProperty[0]; + + private final ReferenceValidator bookingItemReferenceValidation; + private final ReferenceValidator parentAssetReferenceValidation; + private final ReferenceValidator assignedToAssetReferenceValidation; + private final HostingAssetEntityValidator.AlarmContact alarmContactValidation; + + HostingAssetEntityValidator( + final HsHostingAssetType assetType, + final AlarmContact alarmContactValidation, + final ValidatableProperty... properties) { + super(properties); + this.bookingItemReferenceValidation = new ReferenceValidator<>( + assetType.bookingItemPolicy(), + assetType.bookingItemType(), + HsHostingAssetEntity::getBookingItem, + HsBookingItemEntity::getType); + this.parentAssetReferenceValidation = new ReferenceValidator<>( + assetType.parentAssetPolicy(), + assetType.parentAssetType(), + HsHostingAssetEntity::getParentAsset, + HsHostingAssetEntity::getType); + this.assignedToAssetReferenceValidation = new ReferenceValidator<>( + assetType.assignedToAssetPolicy(), + assetType.assignedToAssetType(), + HsHostingAssetEntity::getAssignedToAsset, + HsHostingAssetEntity::getType); + this.alarmContactValidation = alarmContactValidation; + } + + @Override + public List validateEntity(final HsHostingAssetEntity assetEntity) { + return sequentiallyValidate( + () -> validateEntityReferencesAndProperties(assetEntity), + () -> validateIdentifierPattern(assetEntity) + ); + } + + @Override + public List validateContext(final HsHostingAssetEntity assetEntity) { + return sequentiallyValidate( + () -> optionallyValidate(assetEntity.getBookingItem()), + () -> optionallyValidate(assetEntity.getParentAsset()), + () -> validateAgainstSubEntities(assetEntity) + ); + } + + private List validateEntityReferencesAndProperties(final HsHostingAssetEntity assetEntity) { + return Stream.of( + validateReferencedEntity(assetEntity, "bookingItem", bookingItemReferenceValidation::validate), + validateReferencedEntity(assetEntity, "parentAsset", parentAssetReferenceValidation::validate), + validateReferencedEntity(assetEntity, "assignedToAsset", assignedToAssetReferenceValidation::validate), + validateReferencedEntity(assetEntity, "alarmContact", alarmContactValidation::validate), + validateProperties(assetEntity)) + .filter(Objects::nonNull) + .flatMap(List::stream) + .filter(Objects::nonNull) + .toList(); + } + + private List validateReferencedEntity( + final HsHostingAssetEntity assetEntity, + final String referenceFieldName, + final BiFunction> validator) { + return enrich(prefix(assetEntity.toShortString()), validator.apply(assetEntity, referenceFieldName)); + } + + private List validateProperties(final HsHostingAssetEntity assetEntity) { + return enrich(prefix(assetEntity.toShortString(), "config"), super.validateProperties(assetEntity)); + } + + private static List optionallyValidate(final HsHostingAssetEntity assetEntity) { + return assetEntity != null + ? enrich( + prefix(assetEntity.toShortString(), "parentAsset"), + HostingAssetEntityValidatorRegistry.forType(assetEntity.getType()).validateContext(assetEntity)) + : emptyList(); + } + + private static List optionallyValidate(final HsBookingItemEntity bookingItem) { + return bookingItem != null + ? enrich( + prefix(bookingItem.toShortString(), "bookingItem"), + HsBookingItemEntityValidatorRegistry.forType(bookingItem.getType()).validateContext(bookingItem)) + : emptyList(); + } + + protected List validateAgainstSubEntities(final HsHostingAssetEntity assetEntity) { + return enrich( + prefix(assetEntity.toShortString(), "config"), + stream(propertyValidators) + .filter(ValidatableProperty::isTotalsValidator) + .map(prop -> validateMaxTotalValue(assetEntity, prop)) + .filter(Objects::nonNull) + .toList()); + } + + // TODO.test: check, if there are any hosting assets which need this validation at all + private String validateMaxTotalValue( + final HsHostingAssetEntity hostingAsset, + final ValidatableProperty propDef) { + final var propName = propDef.propertyName(); + final var propUnit = ofNullable(propDef.unit()).map(u -> " " + u).orElse(""); + final var totalValue = ofNullable(hostingAsset.getSubHostingAssets()).orElse(emptyList()) + .stream() + .map(subItem -> propDef.getValue(subItem.getConfig())) + .map(HsEntityValidator::toIntegerWithDefault0) + .reduce(0, Integer::sum); + final var maxValue = getIntegerValueWithDefault0(propDef, hostingAsset.getConfig()); + return totalValue > maxValue + ? "%s' maximum total is %d%s, but actual total %s is %d%s".formatted( + propName, maxValue, propUnit, propName, totalValue, propUnit) + : null; + } + + private List validateIdentifierPattern(final HsHostingAssetEntity assetEntity) { + final var expectedIdentifierPattern = identifierPattern(assetEntity); + if (assetEntity.getIdentifier() == null || + !expectedIdentifierPattern.matcher(assetEntity.getIdentifier()).matches()) { + return List.of( + "'identifier' expected to match '" + expectedIdentifierPattern + "', but is '" + assetEntity.getIdentifier() + + "'"); + } + return Collections.emptyList(); + } + + protected abstract Pattern identifierPattern(HsHostingAssetEntity assetEntity); + + static class ReferenceValidator { + + private final HsHostingAssetType.RelationPolicy policy; + private final T referencedEntityType; + private final Function referencedEntityGetter; + private final Function referencedEntityTypeGetter; + + public ReferenceValidator( + final HsHostingAssetType.RelationPolicy policy, + final T subEntityType, + final Function referencedEntityGetter, + final Function referencedEntityTypeGetter) { + this.policy = policy; + this.referencedEntityType = subEntityType; + this.referencedEntityGetter = referencedEntityGetter; + this.referencedEntityTypeGetter = referencedEntityTypeGetter; + } + + public ReferenceValidator( + final HsHostingAssetType.RelationPolicy policy, + final Function referencedEntityGetter) { + this.policy = policy; + this.referencedEntityType = null; + this.referencedEntityGetter = referencedEntityGetter; + this.referencedEntityTypeGetter = e -> null; + } + + List validate(final HsHostingAssetEntity assetEntity, final String referenceFieldName) { + + final var actualEntity = referencedEntityGetter.apply(assetEntity); + final var actualEntityType = actualEntity != null ? referencedEntityTypeGetter.apply(actualEntity) : null; + + switch (policy) { + case REQUIRED: + if (actualEntityType != referencedEntityType) { + return List.of(actualEntityType == null + ? referenceFieldName + "' must be of type " + referencedEntityType + " but is null" + : referenceFieldName + "' must be of type " + referencedEntityType + " but is of type " + actualEntityType); + } + break; + case OPTIONAL: + if (actualEntityType != null && actualEntityType != referencedEntityType) { + return List.of(referenceFieldName + "' must be null or of type " + referencedEntityType + " but is of type " + + actualEntityType); + } + break; + case FORBIDDEN: + if (actualEntityType != null) { + return List.of(referenceFieldName + "' must be null but is of type " + actualEntityType); + } + break; + } + return emptyList(); + } + } + + static class AlarmContact extends ReferenceValidator> { + + AlarmContact(final HsHostingAssetType.RelationPolicy policy) { + super(policy, HsHostingAssetEntity::getAlarmContact); + } + + static AlarmContact isOptional() { + return new AlarmContact(HsHostingAssetType.RelationPolicy.OPTIONAL); + } + } + +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistry.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistry.java new file mode 100644 index 00000000..c44bf92a --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistry.java @@ -0,0 +1,57 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetResource; +import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; + +import java.util.*; + +import static java.util.Arrays.stream; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.*; + +public class HostingAssetEntityValidatorRegistry { + + private static final Map, HsEntityValidator> validators = new HashMap<>(); + static { + // HOWTO: add (register) new HsHostingAssetType-specific validators + register(CLOUD_SERVER, new HsCloudServerHostingAssetValidator()); + register(MANAGED_SERVER, new HsManagedServerHostingAssetValidator()); + register(MANAGED_WEBSPACE, new HsManagedWebspaceHostingAssetValidator()); + register(UNIX_USER, new HsUnixUserHostingAssetValidator()); + register(EMAIL_ALIAS, new HsEMailAliasHostingAssetValidator()); + register(DOMAIN_SETUP, new HsDomainSetupHostingAssetValidator()); + register(DOMAIN_DNS_SETUP, new HsDomainDnsSetupHostingAssetValidator()); + register(DOMAIN_HTTP_SETUP, new HsDomainHttpSetupHostingAssetValidator()); + register(DOMAIN_SMTP_SETUP, new HsDomainSmtpSetupHostingAssetValidator()); + register(DOMAIN_MBOX_SETUP, new HsDomainMboxSetupHostingAssetValidator()); + register(EMAIL_ADDRESS, new HsEMailAddressHostingAssetValidator()); + } + + private static void register(final Enum type, final HsEntityValidator validator) { + stream(validator.propertyValidators).forEach( entry -> { + entry.verifyConsistency(Map.entry(type, validator)); + }); + validators.put(type, validator); + } + + public static HsEntityValidator forType(final Enum type) { + if ( validators.containsKey(type)) { + return validators.get(type); + } + throw new IllegalArgumentException("no validator found for type " + type); + } + + public static Set> types() { + return validators.keySet(); + } + + @SuppressWarnings("unchecked") + private static Map asMap(final HsHostingAssetResource resource) { + if (resource.getConfig() instanceof Map map) { + return map; + } + throw new IllegalArgumentException("expected a Map, but got a " + resource.getConfig().getClass()); + } + +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidator.java index 8c43dd43..840e5841 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidator.java @@ -1,20 +1,22 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; -import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; -import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; -import static net.hostsharing.hsadminng.hs.validation.IntegerPropertyValidator.integerProperty; +import java.util.regex.Pattern; -class HsCloudServerHostingAssetValidator extends HsEntityValidator { +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; - public HsCloudServerHostingAssetValidator() { +class HsCloudServerHostingAssetValidator extends HostingAssetEntityValidator { + + HsCloudServerHostingAssetValidator() { super( - integerProperty("CPUs").min(1).max(32).required(), - integerProperty("RAM").unit("GB").min(1).max(128).required(), - integerProperty("SSD").unit("GB").min(25).max(1000).step(25).required(), - integerProperty("HDD").unit("GB").min(0).max(4000).step(250).optional(), - integerProperty("Traffic").unit("GB").min(250).max(10000).step(250).required() - ); + CLOUD_SERVER, + AlarmContact.isOptional(), + NO_EXTRA_PROPERTIES); + } + + @Override + protected Pattern identifierPattern(final HsHostingAssetEntity assetEntity) { + return Pattern.compile("^vm[0-9][0-9][0-9][0-9]$"); } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidator.java new file mode 100644 index 00000000..97c44ce2 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidator.java @@ -0,0 +1,110 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import lombok.SneakyThrows; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; +import net.hostsharing.hsadminng.system.SystemProcess; + +import java.util.List; +import java.util.regex.Pattern; + +import static java.util.Arrays.stream; +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_DNS_SETUP; +import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf; +import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +class HsDomainDnsSetupHostingAssetValidator extends HostingAssetEntityValidator { + + // according to RFC 1035 (section 5) and RFC 1034 + static final String RR_REGEX_NAME = "([a-z0-9\\.-]+|@)\\s+"; + static final String RR_REGEX_TTL = "(([1-9][0-9]*[mMhHdDwW]{0,1})+\\s+)*"; + static final String RR_REGEX_IN = "IN\\s+"; // record class IN for Internet + static final String RR_RECORD_TYPE = "[A-Z]+\\s+"; + static final String RR_RECORD_DATA = "[^;].*"; + static final String RR_COMMENT = "(;.*)*"; + + static final String RR_REGEX_TTL_IN = + RR_REGEX_NAME + RR_REGEX_TTL + RR_REGEX_IN + RR_RECORD_TYPE + RR_RECORD_DATA + RR_COMMENT; + + static final String RR_REGEX_IN_TTL = + RR_REGEX_NAME + RR_REGEX_IN + RR_REGEX_TTL + RR_RECORD_TYPE + RR_RECORD_DATA + RR_COMMENT; + public static final String IDENTIFIER_SUFFIX = "|DNS"; + + HsDomainDnsSetupHostingAssetValidator() { + super( + DOMAIN_DNS_SETUP, + AlarmContact.isOptional(), + + integerProperty("TTL").min(0).withDefault(21600), + booleanProperty("auto-SOA-RR").withDefault(true), + booleanProperty("auto-NS-RR").withDefault(true), + booleanProperty("auto-MX-RR").withDefault(true), + booleanProperty("auto-A-RR").withDefault(true), + booleanProperty("auto-AAAA-RR").withDefault(true), + booleanProperty("auto-MAILSERVICES-RR").withDefault(true), + booleanProperty("auto-AUTOCONFIG-RR").withDefault(true), // TODO.spec: does that already exist? + booleanProperty("auto-AUTODISCOVER-RR").withDefault(true), + booleanProperty("auto-DKIM-RR").withDefault(true), + booleanProperty("auto-SPF-RR").withDefault(true), + booleanProperty("auto-WILDCARD-MX-RR").withDefault(true), + booleanProperty("auto-WILDCARD-A-RR").withDefault(true), + booleanProperty("auto-WILDCARD-AAAA-RR").withDefault(true), + booleanProperty("auto-WILDCARD-DKIM-RR").withDefault(true), // TODO.spec: check, if that really works + booleanProperty("auto-WILDCARD-SPF-RR").withDefault(true), + arrayOf( + stringProperty("user-RR").matchesRegEx(RR_REGEX_TTL_IN, RR_REGEX_IN_TTL).required() + ).optional()); + } + + @Override + protected Pattern identifierPattern(final HsHostingAssetEntity assetEntity) { + return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$"); + } + + @Override + public void preprocessEntity(final HsHostingAssetEntity entity) { + super.preprocessEntity(entity); + if (entity.getIdentifier() == null) { + ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX)); + } + } + + @Override + @SneakyThrows + public List validateContext(final HsHostingAssetEntity assetEntity) { + final var result = super.validateContext(assetEntity); + + // TODO.spec: define which checks should get raised to error level + final var namedCheckZone = new SystemProcess("named-checkzone", fqdn(assetEntity)); + if (namedCheckZone.execute(toZonefileString(assetEntity)) != 0) { + // yes, named-checkzone writes error messages to stdout + stream(namedCheckZone.getStdOut().split("\n")) + .map(line -> line.replaceAll(" stream-0x[0-9a-f:]+", "")) + .forEach(result::add); + } + return result; + } + + String toZonefileString(final HsHostingAssetEntity assetEntity) { + // TODO.spec: we need to expand the templates (auto-...) in the same way as in Saltstack + return """ + $ORIGIN {domain}. + $TTL {ttl} + + ; these records are just placeholders to create a valid zonefile for the validation + @ 1814400 IN SOA {domain}. root.{domain} ( 1999010100 10800 900 604800 86400 ) + @ IN NS ns + + {userRRs} + """ + .replace("{domain}", fqdn(assetEntity)) + .replace("{ttl}", getPropertyValue(assetEntity, "TTL")) + .replace("{userRRs}", getPropertyValues(assetEntity, "user-RR") ); + } + + private String fqdn(final HsHostingAssetEntity assetEntity) { + return assetEntity.getIdentifier().substring(0, assetEntity.getIdentifier().length()-IDENTIFIER_SUFFIX.length()); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidator.java new file mode 100644 index 00000000..32a2cb30 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidator.java @@ -0,0 +1,56 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; + +import java.util.regex.Pattern; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_HTTP_SETUP; +import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf; +import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +class HsDomainHttpSetupHostingAssetValidator extends HostingAssetEntityValidator { + + public static final String IDENTIFIER_SUFFIX = "|HTTP"; + public static final String FILESYSTEM_PATH = "^/"; + public static final String PARTIAL_DOMAIN_NAME_REGEX = "(?!-)[A-Za-z0-9-]{1,63}(? entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX)); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainMboxSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainMboxSetupHostingAssetValidator.java new file mode 100644 index 00000000..0172fda4 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainMboxSetupHostingAssetValidator.java @@ -0,0 +1,34 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; + +import java.util.regex.Pattern; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_MBOX_SETUP; + +class HsDomainMboxSetupHostingAssetValidator extends HostingAssetEntityValidator { + + public static final String IDENTIFIER_SUFFIX = "|MBOX"; + + HsDomainMboxSetupHostingAssetValidator() { + super( + DOMAIN_MBOX_SETUP, + AlarmContact.isOptional(), + + NO_EXTRA_PROPERTIES); + } + + @Override + protected Pattern identifierPattern(final HsHostingAssetEntity assetEntity) { + return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$"); + } + + @Override + public void preprocessEntity(final HsHostingAssetEntity entity) { + super.preprocessEntity(entity); + if (entity.getIdentifier() == null) { + ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX)); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java new file mode 100644 index 00000000..17031c5e --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java @@ -0,0 +1,57 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; + +import java.util.List; +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP; + +class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator { + + public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(? validateEntity(final HsHostingAssetEntity assetEntity) { + // TODO.impl: for newly created entities, check the permission of setting up a domain + // + // reject, if the domain is any of these: + // hostsharing.com|net|org|coop, // just to be on the safe side + // [^.}+, // top-level-domain + // co.uk, org.uk, gov.uk, ac.uk, sch.uk, + // com.au, net.au, org.au, edu.au, gov.au, asn.au, id.au, + // co.jp, ne.jp, or.jp, ac.jp, go.jp, + // com.cn, net.cn, org.cn, gov.cn, edu.cn, ac.cn, + // com.br, net.br, org.br, gov.br, edu.br, mil.br, art.br, + // co.in, net.in, org.in, gen.in, firm.in, ind.in, + // com.mx, net.mx, org.mx, gob.mx, edu.mx, + // gov.it, edu.it, + // co.nz, net.nz, org.nz, govt.nz, ac.nz, school.nz, geek.nz, kiwi.nz, + // co.kr, ne.kr, or.kr, go.kr, re.kr, pe.kr + // + // allow if + // - user has Admin/Agent-role for all its sub-domains and the direct parent-Domain which are set up at at Hostsharing + // - domain has DNS zone with TXT record approval + // - parent-domain has DNS zone with TXT record approval + // + // TXT-Record check: + // new InitialDirContext().getAttributes("dns:_netblocks.google.com", new String[] { "TXT"}).get("TXT").getAll(); + + return super.validateEntity(assetEntity); + } + + @Override + protected Pattern identifierPattern(final HsHostingAssetEntity assetEntity) { + return identifierPattern; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSmtpSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSmtpSetupHostingAssetValidator.java new file mode 100644 index 00000000..e92eba10 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSmtpSetupHostingAssetValidator.java @@ -0,0 +1,34 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; + +import java.util.regex.Pattern; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SMTP_SETUP; + +class HsDomainSmtpSetupHostingAssetValidator extends HostingAssetEntityValidator { + + public static final String IDENTIFIER_SUFFIX = "|SMTP"; + + HsDomainSmtpSetupHostingAssetValidator() { + super( + DOMAIN_SMTP_SETUP, + AlarmContact.isOptional(), + + NO_EXTRA_PROPERTIES); + } + + @Override + protected Pattern identifierPattern(final HsHostingAssetEntity assetEntity) { + return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$"); + } + + @Override + public void preprocessEntity(final HsHostingAssetEntity entity) { + super.preprocessEntity(entity); + if (entity.getIdentifier() == null) { + ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX)); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidator.java new file mode 100644 index 00000000..d77451e7 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidator.java @@ -0,0 +1,51 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; + +import java.util.regex.Pattern; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +class HsEMailAddressHostingAssetValidator extends HostingAssetEntityValidator { + + private static final String UNIX_USER_REGEX = "^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9]+)?$"; // also accepts legacy pac-names + private static final String EMAIL_ADDRESS_LOCAL_PART_REGEX = "[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+"; // RFC 5322 + private static final String EMAIL_ADDRESS_DOMAIN_PART_REGEX = "[a-zA-Z0-9.-]+"; + private static final String EMAIL_ADDRESS_FULL_REGEX = "^" + EMAIL_ADDRESS_LOCAL_PART_REGEX + "@" + EMAIL_ADDRESS_DOMAIN_PART_REGEX + "$"; + public static final int EMAIL_ADDRESS_MAX_LENGTH = 320; // according to RFC 5321 and RFC 5322 + + HsEMailAddressHostingAssetValidator() { + super( HsHostingAssetType.EMAIL_ADDRESS, + AlarmContact.isOptional(), + + stringProperty("local-part").matchesRegEx("^" + EMAIL_ADDRESS_LOCAL_PART_REGEX + "$").required(), + stringProperty("sub-domain").matchesRegEx("^" + EMAIL_ADDRESS_LOCAL_PART_REGEX + "$").optional(), + arrayOf( + stringProperty("target").maxLength(EMAIL_ADDRESS_MAX_LENGTH).matchesRegEx(UNIX_USER_REGEX, EMAIL_ADDRESS_FULL_REGEX) + ).required().minLength(1)); + } + + @Override + public void preprocessEntity(final HsHostingAssetEntity entity) { + super.preprocessEntity(entity); + super.preprocessEntity(entity); + if (entity.getIdentifier() == null) { + entity.setIdentifier(combineIdentifier(entity)); + } + } + + @Override + protected Pattern identifierPattern(final HsHostingAssetEntity assetEntity) { + return Pattern.compile("^"+ Pattern.quote(combineIdentifier(assetEntity)) + "$"); + } + + private static String combineIdentifier(final HsHostingAssetEntity emailAddressAssetEntity) { + return emailAddressAssetEntity.getDirectValue("local-part", String.class) + + ofNullable(emailAddressAssetEntity.getDirectValue("sub-domain", String.class)).map(s -> "." + s).orElse("") + + "@" + + emailAddressAssetEntity.getParentAsset().getIdentifier(); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAliasHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAliasHostingAssetValidator.java new file mode 100644 index 00000000..d9bcb01a --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAliasHostingAssetValidator.java @@ -0,0 +1,31 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; + +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +class HsEMailAliasHostingAssetValidator extends HostingAssetEntityValidator { + + private static final String UNIX_USER_REGEX = "^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9]+)?$"; // also accepts legacy pac-names + private static final String EMAIL_ADDRESS_REGEX = "^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$"; // RFC 5322 + public static final int EMAIL_ADDRESS_MAX_LENGTH = 320; // according to RFC 5321 and RFC 5322 + + HsEMailAliasHostingAssetValidator() { + super( HsHostingAssetType.EMAIL_ALIAS, + AlarmContact.isOptional(), + + arrayOf( + stringProperty("target").maxLength(EMAIL_ADDRESS_MAX_LENGTH).matchesRegEx(UNIX_USER_REGEX, EMAIL_ADDRESS_REGEX) + ).required().minLength(1)); + } + + @Override + protected Pattern identifierPattern(final HsHostingAssetEntity assetEntity) { + final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier(); + return Pattern.compile("^"+webspaceIdentifier+"$|^"+webspaceIdentifier+"-[a-z0-9]+$"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsHostingAssetEntityValidators.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsHostingAssetEntityValidators.java deleted file mode 100644 index c4eaef0f..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsHostingAssetEntityValidators.java +++ /dev/null @@ -1,51 +0,0 @@ -package net.hostsharing.hsadminng.hs.hosting.asset.validators; - -import lombok.experimental.UtilityClass; -import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; -import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; -import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; - -import jakarta.validation.ValidationException; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; - -import static java.util.Arrays.stream; -import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; -import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; -import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; - -@UtilityClass -public class HsHostingAssetEntityValidators { - - private static final Map, HsEntityValidator> validators = new HashMap<>(); - static { - register(CLOUD_SERVER, new HsCloudServerHostingAssetValidator()); - register(MANAGED_SERVER, new HsManagedServerHostingAssetValidator()); - register(MANAGED_WEBSPACE, new HsManagedWebspaceHostingAssetValidator()); - } - - private static void register(final Enum type, final HsEntityValidator validator) { - stream(validator.propertyValidators).forEach( entry -> { - entry.verifyConsistency(Map.entry(type, validator)); - }); - validators.put(type, validator); - } - - public static HsEntityValidator forType(final Enum type) { - return validators.get(type); - } - - public static Set> types() { - return validators.keySet(); - } - - - public static HsHostingAssetEntity valid(final HsHostingAssetEntity entityToSave) { - final var violations = HsHostingAssetEntityValidators.forType(entityToSave.getType()).validate(entityToSave); - if (!violations.isEmpty()) { - throw new ValidationException(violations.toString()); - } - return entityToSave; - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidator.java index aee10839..0397b79e 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidator.java @@ -1,20 +1,60 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; -import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; -import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; -import static net.hostsharing.hsadminng.hs.validation.IntegerPropertyValidator.integerProperty; +import java.util.regex.Pattern; -class HsManagedServerHostingAssetValidator extends HsEntityValidator { +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty; +import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; + +class HsManagedServerHostingAssetValidator extends HostingAssetEntityValidator { public HsManagedServerHostingAssetValidator() { super( - integerProperty("CPUs").min(1).max(32).required(), - integerProperty("RAM").unit("GB").min(1).max(128).required(), - integerProperty("SSD").unit("GB").min(25).max(1000).step(25).required(), - integerProperty("HDD").unit("GB").min(0).max(4000).step(250).optional(), - integerProperty("Traffic").unit("GB").min(250).max(10000).step(250).required() + MANAGED_SERVER, + AlarmContact.isOptional(), // hostmaster alert address is implicitly added + + // monitoring + integerProperty("monit_max_cpu_usage").unit("%").min(10).max(100).withDefault(92), + integerProperty("monit_max_ram_usage").unit("%").min(10).max(100).withDefault(92), + integerProperty("monit_max_ssd_usage").unit("%").min(10).max(100).withDefault(98), + integerProperty("monit_min_free_ssd").min(1).max(1000).withDefault(5), + integerProperty("monit_max_hdd_usage").unit("%").min(10).max(100).withDefault(95), + integerProperty("monit_min_free_hdd").min(1).max(4000).withDefault(10), + + // other settings + // booleanProperty("fastcgi_small").withDefault(false), TODO.spec: clarify Salt-Grains + + // database software + booleanProperty("software-pgsql").withDefault(true), + booleanProperty("software-mariadb").withDefault(true), + + // PHP + enumerationProperty("php-default").valuesFromProperties("software-php-").withDefault("8.2"), + booleanProperty("software-php-5.6").withDefault(false), + booleanProperty("software-php-7.0").withDefault(false), + booleanProperty("software-php-7.1").withDefault(false), + booleanProperty("software-php-7.2").withDefault(false), + booleanProperty("software-php-7.3").withDefault(false), + booleanProperty("software-php-7.4").withDefault(true), + booleanProperty("software-php-8.0").withDefault(false), + booleanProperty("software-php-8.1").withDefault(false), + booleanProperty("software-php-8.2").withDefault(true), + + // other software + booleanProperty("software-postfix-tls-1.0").withDefault(false), + booleanProperty("software-dovecot-tls-1.0").withDefault(false), + booleanProperty("software-clamav").withDefault(true), + booleanProperty("software-collabora").withDefault(false), + booleanProperty("software-libreoffice").withDefault(false), + booleanProperty("software-imagemagick-ghostscript").withDefault(false) ); } + + @Override + protected Pattern identifierPattern(final HsHostingAssetEntity assetEntity) { + return Pattern.compile("^vm[0-9][0-9][0-9][0-9]$"); + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidator.java index 452bb116..8345c5fc 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidator.java @@ -1,34 +1,25 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; -import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; -import net.hostsharing.hsadminng.hs.validation.HsEntityValidator; -import java.util.List; +import java.util.regex.Pattern; -import static net.hostsharing.hsadminng.hs.validation.IntegerPropertyValidator.integerProperty; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; -class HsManagedWebspaceHostingAssetValidator extends HsEntityValidator { +class HsManagedWebspaceHostingAssetValidator extends HostingAssetEntityValidator { public HsManagedWebspaceHostingAssetValidator() { super( - integerProperty("SSD").unit("GB").min(1).max(100).step(1).required(), - integerProperty("HDD").unit("GB").min(0).max(250).step(10).optional(), - integerProperty("Traffic").unit("GB").min(10).max(1000).step(10).required() - ); + MANAGED_WEBSPACE, + AlarmContact.isOptional(), // hostmaster alert address is implicitly added + NO_EXTRA_PROPERTIES); } @Override - public List validate(final HsHostingAssetEntity assetEntity) { - final var result = super.validate(assetEntity); - validateIdentifierPattern(result, assetEntity); - - return result; - } - - private static void validateIdentifierPattern(final List result, final HsHostingAssetEntity assetEntity) { - final var expectedIdentifierPattern = "^" + assetEntity.getParentAsset().getBookingItem().getDebitor().getDefaultPrefix() + "[0-9][0-9]$"; - if ( !assetEntity.getIdentifier().matches(expectedIdentifierPattern)) { - result.add("'identifier' expected to match '"+expectedIdentifierPattern+"', but is '" + assetEntity.getIdentifier() + "'"); - } + protected Pattern identifierPattern(final HsHostingAssetEntity assetEntity) { + final var prefixPattern = + !assetEntity.isLoaded() + ? assetEntity.getParentAsset().getBookingItem().getProject().getDebitor().getDefaultPrefix() + : "[a-z][a-z0-9][a-z0-9]"; + return Pattern.compile("^" + prefixPattern + "[0-9][0-9]$"); } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java new file mode 100644 index 00000000..1d44f6ac --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidator.java @@ -0,0 +1,48 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hash.LinuxEtcShadowHashGenerator; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import net.hostsharing.hsadminng.hs.validation.PropertiesProvider; + +import java.util.regex.Pattern; + +import static net.hostsharing.hsadminng.hs.validation.EnumerationProperty.enumerationProperty; +import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty; +import static net.hostsharing.hsadminng.hs.validation.PasswordProperty.passwordProperty; +import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty; + +class HsUnixUserHostingAssetValidator extends HostingAssetEntityValidator { + + private static final int DASH_LENGTH = "-".length(); + + HsUnixUserHostingAssetValidator() { + super( + HsHostingAssetType.UNIX_USER, + AlarmContact.isOptional(), + + integerProperty("SSD hard quota").unit("GB").maxFrom("SSD").optional(), + integerProperty("SSD soft quota").unit("GB").maxFrom("SSD hard quota").optional(), + integerProperty("HDD hard quota").unit("GB").maxFrom("HDD").optional(), + integerProperty("HDD soft quota").unit("GB").maxFrom("HDD hard quota").optional(), + enumerationProperty("shell") + .values("/bin/false", "/bin/bash", "/bin/csh", "/bin/dash", "/usr/bin/tcsh", "/usr/bin/zsh", "/usr/bin/passwd") + .withDefault("/bin/false"), + stringProperty("homedir").readOnly().computedBy(HsUnixUserHostingAssetValidator::computeHomedir), + stringProperty("totpKey").matchesRegEx("^0x([0-9A-Fa-f]{2})+$").minLength(20).maxLength(256).undisclosed().writeOnly().optional(), + passwordProperty("password").minLength(8).maxLength(40).hashedUsing(LinuxEtcShadowHashGenerator.Algorithm.SHA512).writeOnly()); + } + + @Override + protected Pattern identifierPattern(final HsHostingAssetEntity assetEntity) { + final var webspaceIdentifier = assetEntity.getParentAsset().getIdentifier(); + return Pattern.compile("^"+webspaceIdentifier+"$|^"+webspaceIdentifier+"-[a-z0-9]+$"); + } + + private static String computeHomedir(final PropertiesProvider propertiesProvider) { + final var entity = (HsHostingAssetEntity) propertiesProvider; + final var webspaceName = entity.getParentAsset().getIdentifier(); + return "/home/pacs/" + webspaceName + + "/users/" + entity.getIdentifier().substring(webspaceName.length()+DASH_LENGTH); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/README.md b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/README.md new file mode 100644 index 00000000..52e03058 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/README.md @@ -0,0 +1,40 @@ +### HsHostingAssetEntity-Validation + +There is just a single `HsHostingAssetEntity` class for all types of hosting assets like Managed-Server, Managed-Webspace, Unix-Users, Databases etc. These are distinguished by `HsHostingAssetType HsHostingAssetEntity.type`. + +For each of these types, a distinct validator has to be +implemented as a subclass of `HsHostingAssetEntityValidator` which needs to be registered (see `HsHostingAssetEntityValidatorRegistry`) for the relevant type(s). + +### Kinds of Validations + +#### Identifier validation + +The identifier of a Hosting-Asset is for example the Webspace-Name like "xyz00" or a Unix-User-Name like "xyz00-test". + +To validate the identifier, vverride the method `identifierPattern(...)` and return a regular expression to validate the identifier against. The regular expression can depend on the actual entity instance. + +#### Reference validation + +References in this context are: +- the related Booking-Item, +- the parent-Hosting-Asset, +- the Assigned-To-Hosting-Asset and +- the Contact. + +The first parameters of the `HsHostingAssetEntityValidator` superclass take rule descriptors for these references. These are all Subclasses fo + +### Validation Order + +The validations are called in a sensible order. E.g. if a property value is not numeric, it makes no sense to check the total sum of such values to be within certain numeric values. And if the related booking item is of wrong type, it makes no sense to validate limits against sub-entities. + +Properties are validated all at once, though. Thus, if multiple properties fail validation, all error messages are returned at once. + +In general, the validation es executed in this order: + +1. the entity itself + 1. its references + 2. its properties +2. the limits of the parent entity (parent asset + booking item) +3. limits against the own own-sub-entities + +This implementation can be found in `HsHostingAssetEntityValidator.validate`. diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java index a22065c0..8ec1d956 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionController.java @@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.office.coopassets; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopAssetsApi; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*; +import net.hostsharing.hsadminng.errors.MultiValidationException; import net.hostsharing.hsadminng.mapper.Mapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.format.annotation.DateTimeFormat; @@ -13,14 +14,12 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; import jakarta.persistence.EntityNotFoundException; -import jakarta.validation.ValidationException; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.function.BiConsumer; -import static java.lang.String.join; import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionTypeResource.*; @RestController @@ -97,9 +96,7 @@ public class HsOfficeCoopAssetsTransactionController implements HsOfficeCoopAsse validateDebitTransaction(requestBody, violations); validateCreditTransaction(requestBody, violations); validateAssetValue(requestBody, violations); - if (violations.size() > 0) { - throw new ValidationException("[" + join(", ", violations) + "]"); - } + MultiValidationException.throwIfNotEmpty(violations); } private static void validateDebitTransaction( diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java index 9a3295a2..78b41c9f 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionController.java @@ -5,6 +5,7 @@ import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopSharesApi; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopSharesTransactionInsertResource; import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopSharesTransactionResource; +import net.hostsharing.hsadminng.errors.MultiValidationException; import net.hostsharing.hsadminng.mapper.Mapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.format.annotation.DateTimeFormat; @@ -14,14 +15,12 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder; -import jakarta.validation.ValidationException; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.function.BiConsumer; -import static java.lang.String.join; import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopSharesTransactionTypeResource.CANCELLATION; import static net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopSharesTransactionTypeResource.SUBSCRIPTION; @@ -99,9 +98,7 @@ public class HsOfficeCoopSharesTransactionController implements HsOfficeCoopShar validateSubscriptionTransaction(requestBody, violations); validateCancellationTransaction(requestBody, violations); validateshareCount(requestBody, violations); - if (violations.size() > 0) { - throw new ValidationException("[" + join(", ", violations) + "]"); - } + MultiValidationException.throwIfNotEmpty(violations); } private static void validateSubscriptionTransaction( diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/ArrayProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/ArrayProperty.java new file mode 100644 index 00000000..9001ea81 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/ArrayProperty.java @@ -0,0 +1,63 @@ +package net.hostsharing.hsadminng.hs.validation; + +import lombok.Setter; + +import java.util.Arrays; +import java.util.List; + +import static java.util.Arrays.stream; +import static net.hostsharing.hsadminng.mapper.Array.insertNewEntriesAfterExistingEntry; + +@Setter +public class ArrayProperty

, E> extends ValidatableProperty, E[]> { + + private static final String[] KEY_ORDER = + insertNewEntriesAfterExistingEntry( + insertNewEntriesAfterExistingEntry(ValidatableProperty.KEY_ORDER, "required", "minLength" ,"maxLength"), + "propertyName", "elementsOf"); + private final ValidatableProperty elementsOf; + private Integer minLength; + private Integer maxLength; + + private ArrayProperty(final ValidatableProperty elementsOf) { + //noinspection unchecked + super((Class) elementsOf.type.arrayType(), elementsOf.propertyName, KEY_ORDER); + this.elementsOf = elementsOf; + } + + public static ArrayProperty arrayOf(final ValidatableProperty elementsOf) { + //noinspection unchecked + return (ArrayProperty) new ArrayProperty<>(elementsOf); + } + + public ValidatableProperty minLength(final int minLength) { + this.minLength = minLength; + return self(); + } + + public ValidatableProperty maxLength(final int maxLength) { + this.maxLength = maxLength; + return self(); + } + + @Override + protected void validate(final List result, final E[] propValue, final PropertiesProvider propProvider) { + if (minLength != null && propValue.length < minLength) { + result.add(propertyName + "' length is expected to be at min " + minLength + " but length of " + display(propValue) + " is " + propValue.length); + } + if (maxLength != null && propValue.length > maxLength) { + result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + display(propValue) + " is " + propValue.length); + } + stream(propValue).forEach(e -> elementsOf.validate(result, e, propProvider)); + } + + @Override + protected String simpleTypeName() { + return elementsOf.simpleTypeName() + "[]"; + } + + @SafeVarargs + private String display(final E... propValue) { + return "[" + Arrays.toString(propValue) + "]"; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/BooleanProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/BooleanProperty.java new file mode 100644 index 00000000..abe5f7b4 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/BooleanProperty.java @@ -0,0 +1,46 @@ +package net.hostsharing.hsadminng.hs.validation; + +import lombok.Setter; +import net.hostsharing.hsadminng.mapper.Array; + +import java.util.AbstractMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Setter +public class BooleanProperty extends ValidatableProperty { + + private static final String[] KEY_ORDER = Array.join(ValidatableProperty.KEY_ORDER_HEAD, ValidatableProperty.KEY_ORDER_TAIL); + + private Map.Entry falseIf; + + private BooleanProperty(final String propertyName) { + super(Boolean.class, propertyName, KEY_ORDER); + } + + public static BooleanProperty booleanProperty(final String propertyName) { + return new BooleanProperty(propertyName); + } + + public BooleanProperty falseIf(final String refPropertyName, final String refPropertyValue) { + this.falseIf = new AbstractMap.SimpleImmutableEntry<>(refPropertyName, refPropertyValue); + return this; + } + + @Override + protected void validate(final List result, final Boolean propValue, final PropertiesProvider propProvider) { + if (falseIf != null && propValue) { + final Object referencedValue = propProvider.directProps().get(falseIf.getKey()); + if (Objects.equals(referencedValue, falseIf.getValue())) { + result.add(propertyName + "' is expected to be false because " + + falseIf.getKey() + "=" + referencedValue + " but is " + propValue); + } + } + } + + @Override + protected String simpleTypeName() { + return "boolean"; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/BooleanPropertyValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/BooleanPropertyValidator.java deleted file mode 100644 index 2838e0f5..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/BooleanPropertyValidator.java +++ /dev/null @@ -1,42 +0,0 @@ -package net.hostsharing.hsadminng.hs.validation; - -import lombok.Setter; - -import java.util.AbstractMap; -import java.util.ArrayList; -import java.util.Map; -import java.util.Objects; - -@Setter -public class BooleanPropertyValidator extends HsPropertyValidator { - - private Map.Entry falseIf; - - private BooleanPropertyValidator(final String propertyName) { - super(Boolean.class, propertyName); - } - - public static BooleanPropertyValidator booleanProperty(final String propertyName) { - return new BooleanPropertyValidator(propertyName); - } - - public HsPropertyValidator falseIf(final String refPropertyName, final String refPropertyValue) { - this.falseIf = new AbstractMap.SimpleImmutableEntry<>(refPropertyName, refPropertyValue); - return this; - } - - @Override - protected void validate(final ArrayList result, final String propertiesName, final Boolean propValue, final Map props) { - if (falseIf != null && !Objects.equals(props.get(falseIf.getKey()), falseIf.getValue())) { - if (propValue) { - result.add("'"+propertiesName+"." + propertyName + "' is expected to be false because " + - propertiesName+"." + falseIf.getKey()+ "=" + falseIf.getValue() + " but is " + propValue); - } - } - } - - @Override - protected String simpleTypeName() { - return "boolean"; - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/EnumerationProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/EnumerationProperty.java new file mode 100644 index 00000000..60e0f244 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/EnumerationProperty.java @@ -0,0 +1,63 @@ +package net.hostsharing.hsadminng.hs.validation; + +import lombok.Setter; +import net.hostsharing.hsadminng.mapper.Array; + +import java.util.Arrays; +import java.util.List; + +import static java.util.Arrays.stream; + +@Setter +public class EnumerationProperty extends ValidatableProperty { + + private static final String[] KEY_ORDER = Array.join( + ValidatableProperty.KEY_ORDER_HEAD, + Array.of("values"), + ValidatableProperty.KEY_ORDER_TAIL); + + private String[] values; + + private EnumerationProperty(final String propertyName) { + super(String.class, propertyName, KEY_ORDER); + } + + public static EnumerationProperty enumerationProperty(final String propertyName) { + return new EnumerationProperty(propertyName); + } + + public EnumerationProperty values(final String... values) { + this.values = values; + return this; + } + + public void deferredInit(final ValidatableProperty[] allProperties) { + if (hasDeferredInit()) { + if (this.values != null) { + throw new IllegalStateException("property " + this + " already has values"); + } + this.values = doDeferredInit(allProperties); + } + } + + public EnumerationProperty valuesFromProperties(final String propertyNamePrefix) { + this.setDeferredInit( (ValidatableProperty[] allProperties) -> stream(allProperties) + .map(ValidatableProperty::propertyName) + .filter(name -> name.startsWith(propertyNamePrefix)) + .map(name -> name.substring(propertyNamePrefix.length())) + .toArray(String[]::new)); + return this; + } + + @Override + protected void validate(final List result, final String propValue, final PropertiesProvider propProvider) { + if (stream(values).noneMatch(v -> v.equals(propValue))) { + result.add(propertyName + "' is expected to be one of " + Arrays.toString(values) + " but is '" + propValue + "'"); + } + } + + @Override + protected String simpleTypeName() { + return "enumeration"; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/EnumerationPropertyValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/EnumerationPropertyValidator.java deleted file mode 100644 index 329feb74..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/EnumerationPropertyValidator.java +++ /dev/null @@ -1,38 +0,0 @@ -package net.hostsharing.hsadminng.hs.validation; - -import lombok.Setter; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Map; - -@Setter -public class EnumerationPropertyValidator extends HsPropertyValidator { - - private String[] values; - - private EnumerationPropertyValidator(final String propertyName) { - super(String.class, propertyName); - } - - public static EnumerationPropertyValidator enumerationProperty(final String propertyName) { - return new EnumerationPropertyValidator(propertyName); - } - - public HsPropertyValidator values(final String... values) { - this.values = values; - return this; - } - - @Override - protected void validate(final ArrayList result, final String propertiesName, final String propValue, final Map props) { - if (Arrays.stream(values).noneMatch(v -> v.equals(propValue))) { - result.add("'"+propertiesName+"." + propertyName + "' is expected to be one of " + Arrays.toString(values) + " but is '" + propValue + "'"); - } - } - - @Override - protected String simpleTypeName() { - return "enumeration"; - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java index 43be4d10..fac624cf 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/HsEntityValidator.java @@ -1,49 +1,144 @@ package net.hostsharing.hsadminng.hs.validation; -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.PropertyAccessor; -import com.fasterxml.jackson.databind.ObjectMapper; + import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.stream.Collectors; import static java.util.Arrays.stream; +import static java.util.Collections.emptyList; -public class HsEntityValidator, T extends Enum> { +// TODO.refa: rename to HsEntityProcessor, also subclasses +public abstract class HsEntityValidator { - public final HsPropertyValidator[] propertyValidators; + public final ValidatableProperty[] propertyValidators; - public HsEntityValidator(final HsPropertyValidator... validators) { + public > HsEntityValidator(final ValidatableProperty... validators) { propertyValidators = validators; + stream(propertyValidators).forEach(p -> p.deferredInit(propertyValidators)); } - public List validate(final E assetEntity) { - final var result = new ArrayList(); - assetEntity.getProperties().keySet().forEach( givenPropName -> { - if (stream(propertyValidators).map(pv -> pv.propertyName).noneMatch(propName -> propName.equals(givenPropName))) { - result.add("'"+assetEntity.getPropertiesName()+"." + givenPropName + "' is not expected but is set to '" +assetEntity.getProperties().get(givenPropName) + "'"); - } - }); - stream(propertyValidators).forEach(pv -> { - result.addAll(pv.validate(assetEntity.getPropertiesName(), assetEntity.getProperties())); - }); - return result; - } - - public List> properties() { - final var mapper = new ObjectMapper(); - mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); - return Arrays.stream(propertyValidators) - .map(propertyValidator -> propertyValidator.toMap(mapper)) - .map(HsEntityValidator::asKeyValueMap) + protected static List enrich(final String prefix, final List messages) { + return messages.stream() + // TODO:refa: this is a bit hacky, I need to find the right place to add the prefix + .map(message -> message.startsWith("'") ? message : ("'" + prefix + "." + message)) .toList(); } - @SuppressWarnings({ "unchecked", "rawtypes" }) - private static Map asKeyValueMap(final Map map) { - return (Map) map; + protected static String prefix(final String... parts) { + return String.join(".", parts); } + public abstract List validateEntity(final E entity); + public abstract List validateContext(final E entity); + + public final List> properties() { + return Arrays.stream(propertyValidators) + .map(ValidatableProperty::toOrderedMap) + .toList(); + } + + public final Map> propertiesMap() { + return Arrays.stream(propertyValidators) + .map(ValidatableProperty::toOrderedMap) + .collect(Collectors.toMap(p -> p.get("propertyName").toString(), p -> p)); + } + + /** + Gets called before any validations take place. + Allows to initialize fields and properties to default values. + */ + public void preprocessEntity(final E entity) { + } + + protected ArrayList validateProperties(final PropertiesProvider propsProvider) { + final var result = new ArrayList(); + + // verify that all actually given properties are specified + final var properties = propsProvider.directProps(); + properties.keySet().forEach( givenPropName -> { + if (stream(propertyValidators).map(pv -> pv.propertyName).noneMatch(propName -> propName.equals(givenPropName))) { + result.add(givenPropName + "' is not expected but is set to '" + properties.get(givenPropName) + "'"); + } + }); + + // run all property validators + stream(propertyValidators).forEach(pv -> { + result.addAll(pv.validate(propsProvider)); + }); + + return result; + } + + @SafeVarargs + public static List sequentiallyValidate(final Supplier>... validators) { + return new ArrayList<>(stream(validators) + .map(Supplier::get) + .filter(violations -> !violations.isEmpty()) + .findFirst() + .orElse(emptyList())); + } + + protected static Integer getIntegerValueWithDefault0(final ValidatableProperty prop, final Map propValues) { + final var value = prop.getValue(propValues); + if (value instanceof Integer) { + return (Integer) value; + } + if (value == null) { + return 0; + } + throw new IllegalArgumentException(prop.propertyName + " Integer value expected, but got " + value); + } + + protected static Integer toIntegerWithDefault0(final Object value) { + if (value instanceof Integer) { + return (Integer) value; + } + if (value == null) { + return 0; + } + throw new IllegalArgumentException("Integer value (or null) expected, but got " + value); + } + + public void prepareProperties(final E entity) { + stream(propertyValidators).forEach(p -> { + if ( p.isWriteOnly() && p.isComputed()) { + entity.directProps().put(p.propertyName, p.compute(entity)); + } + }); + } + + public Map revampProperties(final E entity, final Map config) { + final var copy = new HashMap<>(config); + stream(propertyValidators).forEach(p -> { + if (p.isWriteOnly()) { + copy.remove(p.propertyName); + } else if (p.isReadOnly() && p.isComputed()) { + copy.put(p.propertyName, p.compute(entity)); + } + }); + return copy; + } + + protected String getPropertyValue(final PropertiesProvider entity, final String propertyName) { + final var rawValue = entity.getDirectValue(propertyName, Object.class); + if (rawValue != null) { + return rawValue.toString(); + } + return Objects.toString(propertiesMap().get(propertyName).get("defaultValue")); + } + + protected String getPropertyValues(final PropertiesProvider entity, final String propertyName) { + final var rawValue = entity.getDirectValue(propertyName, Object[].class); + if (rawValue != null) { + return stream(rawValue).map(Object::toString).collect(Collectors.joining("\n")); + } + return ""; + } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/HsPropertyValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/HsPropertyValidator.java deleted file mode 100644 index 891c8a7a..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/HsPropertyValidator.java +++ /dev/null @@ -1,67 +0,0 @@ -package net.hostsharing.hsadminng.hs.validation; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; - -import java.util.AbstractMap.SimpleImmutableEntry; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -@RequiredArgsConstructor -public abstract class HsPropertyValidator { - - final Class type; - final String propertyName; - private Boolean required; - - public static Map.Entry defType(K k, V v) { - return new SimpleImmutableEntry<>(k, v); - } - - public HsPropertyValidator required() { - required = Boolean.TRUE; - return this; - } - - public HsPropertyValidator optional() { - required = Boolean.FALSE; - return this; - } - - public final List validate(final String propertiesName, final Map props) { - final var result = new ArrayList(); - final var propValue = props.get(propertyName); - if (propValue == null) { - if (required) { - result.add("'"+propertiesName+"." + propertyName + "' is required but missing"); - } - } - if (propValue != null){ - if ( type.isInstance(propValue)) { - //noinspection unchecked - validate(result, propertiesName, (T) propValue, props); - } else { - result.add("'"+propertiesName+"." + propertyName + "' is expected to be of type " + type + ", " + - "but is of type '" + propValue.getClass().getSimpleName() + "'"); - } - } - return result; - } - - protected abstract void validate(final ArrayList result, final String propertiesName, final T propValue, final Map props); - - public void verifyConsistency(final Map.Entry, ?> typeDef) { - if (required == null ) { - throw new IllegalStateException(typeDef.getKey() + "[" + propertyName + "] not fully initialized, please call either .required() or .optional()" ); - } - } - - public Map toMap(final ObjectMapper mapper) { - final Map map = mapper.convertValue(this, Map.class); - map.put("type", simpleTypeName()); - return map; - } - - protected abstract String simpleTypeName(); -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/IntegerProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/IntegerProperty.java new file mode 100644 index 00000000..7021f9e1 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/IntegerProperty.java @@ -0,0 +1,88 @@ +package net.hostsharing.hsadminng.hs.validation; + +import lombok.Setter; +import net.hostsharing.hsadminng.mapper.Array; +import org.apache.commons.lang3.Validate; + +import java.util.List; + +@Setter +public class IntegerProperty extends ValidatableProperty { + + private final static String[] KEY_ORDER = Array.join( + ValidatableProperty.KEY_ORDER_HEAD, + Array.of("unit", "min", "minFrom", "max", "maxFrom", "step"), + ValidatableProperty.KEY_ORDER_TAIL); + + private String unit; + private Integer min; + private String minFrom; + private Integer max; + private String maxFrom; + private Integer step; + + public static IntegerProperty integerProperty(final String propertyName) { + return new IntegerProperty(propertyName); + } + + private IntegerProperty(final String propertyName) { + super(Integer.class, propertyName, KEY_ORDER); + } + + @Override + public void deferredInit(final ValidatableProperty[] allProperties) { + Validate.isTrue(min == null || minFrom == null, "min and minFrom are exclusive, but both are given"); + Validate.isTrue(max == null || maxFrom == null, "max and maxFrom are exclusive, but both are given"); + } + + public IntegerProperty minFrom(final String propertyName) { + minFrom = propertyName; + return this; + } + + public IntegerProperty maxFrom(final String propertyName) { + maxFrom = propertyName; + return this; + } + + @Override + public String unit() { + return unit; + } + + public Integer max() { + return max; + } + + @Override + protected void validate(final List result, final Integer propValue, final PropertiesProvider propProvider) { + validateMin(result, propertyName, propValue, min); + validateMax(result, propertyName, propValue, max); + if (step != null && propValue % step != 0) { + result.add(propertyName + "' is expected to be multiple of " + step + " but is " + propValue); + } + if (minFrom != null) { + validateMin(result, propertyName, propValue, propProvider.getContextValue(minFrom, Integer.class)); + } + if (maxFrom != null) { + validateMax(result, propertyName, propValue, propProvider.getContextValue(maxFrom, Integer.class, 0)); + } + } + + @Override + protected String simpleTypeName() { + return "integer"; + } + + private static void validateMin(final List result, final String propertyName, final Integer propValue, final Integer min) { + if (min != null && propValue < min) { + result.add(propertyName + "' is expected to be at least " + min + " but is " + propValue); + } + } + + private static void validateMax(final List result, final String propertyName, final Integer propValue, final Integer max) { + if (max != null && propValue > max) { + result.add(propertyName + "' is expected to be at most " + max + " but is " + propValue); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/IntegerPropertyValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/IntegerPropertyValidator.java deleted file mode 100644 index d6fb85f5..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/IntegerPropertyValidator.java +++ /dev/null @@ -1,42 +0,0 @@ -package net.hostsharing.hsadminng.hs.validation; - -import lombok.Setter; - -import java.util.ArrayList; -import java.util.Map; - -@Setter -public class IntegerPropertyValidator extends HsPropertyValidator { - - private String unit; - private Integer min; - private Integer max; - private Integer step; - - public static IntegerPropertyValidator integerProperty(final String propertyName) { - return new IntegerPropertyValidator(propertyName); - } - - private IntegerPropertyValidator(final String propertyName) { - super(Integer.class, propertyName); - } - - - @Override - protected void validate(final ArrayList result, final String propertiesName, final Integer propValue, final Map props) { - if (min != null && propValue < min) { - result.add("'"+propertiesName+"." + propertyName + "' is expected to be >= " + min + " but is " + propValue); - } - if (max != null && propValue > max) { - result.add("'"+propertiesName+"." + propertyName + "' is expected to be <= " + max + " but is " + propValue); - } - if (step != null && propValue % step != 0) { - result.add("'"+propertiesName+"." + propertyName + "' is expected to be multiple of " + step + " but is " + propValue); - } - } - - @Override - protected String simpleTypeName() { - return "integer"; - } -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/PasswordProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/PasswordProperty.java new file mode 100644 index 00000000..83cdf975 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/PasswordProperty.java @@ -0,0 +1,80 @@ +package net.hostsharing.hsadminng.hs.validation; + +import net.hostsharing.hsadminng.hash.LinuxEtcShadowHashGenerator.Algorithm; +import lombok.Setter; + +import java.util.List; +import java.util.stream.Stream; + +import static java.util.Optional.ofNullable; +import static net.hostsharing.hsadminng.hash.LinuxEtcShadowHashGenerator.hash; +import static net.hostsharing.hsadminng.mapper.Array.insertNewEntriesAfterExistingEntry; + +@Setter +public class PasswordProperty extends StringProperty { + + private static final String[] KEY_ORDER = insertNewEntriesAfterExistingEntry(StringProperty.KEY_ORDER, "computed", "hashedUsing"); + + private Algorithm hashedUsing; + + private PasswordProperty(final String propertyName) { + super(propertyName, KEY_ORDER); + undisclosed(); + } + + public static PasswordProperty passwordProperty(final String propertyName) { + return new PasswordProperty(propertyName); + } + + @Override + protected void validate(final List result, final String propValue, final PropertiesProvider propProvider) { + super.validate(result, propValue, propProvider); + validatePassword(result, propValue); + } + + public PasswordProperty hashedUsing(final Algorithm algorithm) { + this.hashedUsing = algorithm; + computedBy((entity) + -> ofNullable(entity.getDirectValue(propertyName, String.class)) + .map(password -> hash(password).using(algorithm).withRandomSalt().generate()) + .orElse(null)); + return self(); + } + + @Override + protected String simpleTypeName() { + return "password"; + } + + private void validatePassword(final List result, final String password) { + boolean hasLowerCase = false; + boolean hasUpperCase = false; + boolean hasDigit = false; + boolean hasSpecialChar = false; + boolean containsColon = false; + + for (char c : password.toCharArray()) { + if (Character.isLowerCase(c)) { + hasLowerCase = true; + } else if (Character.isUpperCase(c)) { + hasUpperCase = true; + } else if (Character.isDigit(c)) { + hasDigit = true; + } else if (!Character.isLetterOrDigit(c)) { + hasSpecialChar = true; + } + + if (c == ':') { + containsColon = true; + } + } + + final long groupsCovered = Stream.of(hasLowerCase, hasUpperCase, hasDigit, hasSpecialChar).filter(v->v).count(); + if ( groupsCovered < 3) { + result.add(propertyName + "' must contain at least one character of at least 3 of the following groups: upper case letters, lower case letters, digits, special characters"); + } + if (containsColon) { + result.add(propertyName + "' must not contain colon (':')"); + } + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/PropertiesProvider.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/PropertiesProvider.java new file mode 100644 index 00000000..c4d60fb8 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/PropertiesProvider.java @@ -0,0 +1,31 @@ +package net.hostsharing.hsadminng.hs.validation; + +import java.util.Map; + +public interface PropertiesProvider { + + Map directProps(); + Object getContextValue(final String propName); + + default T getDirectValue(final String propName, final Class clazz) { + return cast(propName, directProps().get(propName), clazz, null); + } + + default T getContextValue(final String propName, final Class clazz) { + return cast(propName, getContextValue(propName), clazz, null); + } + + default T getContextValue(final String propName, final Class clazz, final T defaultValue) { + return cast(propName, getContextValue(propName), clazz, defaultValue); + } + + private static T cast( final String propName, final Object value, final Class clazz, final T defaultValue) { + if (value == null && defaultValue != null) { + return defaultValue; + } + if (value == null || clazz.isInstance(value)) { + return clazz.cast(value); + } + throw new IllegalStateException(propName + " expected to be an "+clazz.getSimpleName()+", but got '" + value + "'"); + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java new file mode 100644 index 00000000..aa804916 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java @@ -0,0 +1,95 @@ +package net.hostsharing.hsadminng.hs.validation; + +import lombok.Setter; +import net.hostsharing.hsadminng.mapper.Array; + +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static java.util.Arrays.stream; + +@Setter +public class StringProperty

> extends ValidatableProperty { + + protected static final String[] KEY_ORDER = Array.join( + ValidatableProperty.KEY_ORDER_HEAD, + Array.of("matchesRegEx", "minLength", "maxLength", "provided"), + ValidatableProperty.KEY_ORDER_TAIL, + Array.of("undisclosed")); + private String[] provided; + private Pattern[] matchesRegEx; + private Integer minLength; + private Integer maxLength; + private boolean undisclosed; + + protected StringProperty(final String propertyName) { + super(String.class, propertyName, KEY_ORDER); + } + + protected StringProperty(final String propertyName, final String[] keyOrder) { + super(String.class, propertyName, keyOrder); + } + + public static StringProperty stringProperty(final String propertyName) { + return new StringProperty<>(propertyName); + } + + public P minLength(final int minLength) { + this.minLength = minLength; + return self(); + } + + public P maxLength(final int maxLength) { + this.maxLength = maxLength; + return self(); + } + + public P matchesRegEx(final String... regExPattern) { + this.matchesRegEx = stream(regExPattern).map(Pattern::compile).toArray(Pattern[]::new); + return self(); + } + + /// predifined values, similar to fixed values in a combobox + public P provided(final String... provided) { + this.provided = provided; + return self(); + } + + /** + * The property value is not disclosed in error messages. + * + * @return this; + */ + public P undisclosed() { + this.undisclosed = true; + return self(); + } + + @Override + protected void validate(final List result, final String propValue, final PropertiesProvider propProvider) { + if (minLength != null && propValue.length()maxLength) { + result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + display(propValue) + " is " + propValue.length()); + } + if (matchesRegEx != null && + stream(matchesRegEx).map(p -> p.matcher(propValue)).noneMatch(Matcher::matches)) { + result.add(propertyName + "' is expected to match any of " + Arrays.toString(matchesRegEx) + " but " + display(propValue) + " does not match" + (matchesRegEx.length>1?" any":"")); + } + if (isReadOnly() && propValue != null) { + result.add(propertyName + "' is readonly but given as " + display(propValue)); + } + } + + private String display(final String propValue) { + return undisclosed ? "provided value" : ("'" + propValue + "'"); + } + + @Override + protected String simpleTypeName() { + return "string"; + } +} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/Validatable.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/Validatable.java deleted file mode 100644 index 6f214b04..00000000 --- a/src/main/java/net/hostsharing/hsadminng/hs/validation/Validatable.java +++ /dev/null @@ -1,13 +0,0 @@ -package net.hostsharing.hsadminng.hs.validation; - - -import java.util.Map; - -public interface Validatable> { - - - Enum getType(); - - String getPropertiesName(); - Map getProperties(); -} diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/ValidatableProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/ValidatableProperty.java new file mode 100644 index 00000000..01daf6aa --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/ValidatableProperty.java @@ -0,0 +1,275 @@ +package net.hostsharing.hsadminng.hs.validation; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.experimental.Accessors; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.mapper.Array; +import org.apache.commons.lang3.function.TriFunction; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; +import static java.util.Collections.emptyList; +import static java.util.Optional.ofNullable; +import static org.apache.commons.lang3.ObjectUtils.isArray; + +@Getter +@RequiredArgsConstructor +public abstract class ValidatableProperty

, T> { + + protected static final String[] KEY_ORDER_HEAD = Array.of("propertyName"); + protected static final String[] KEY_ORDER_TAIL = Array.of("required", "defaultValue", "readOnly", "writeOnly", "computed", "isTotalsValidator", "thresholdPercentage"); + protected static final String[] KEY_ORDER = Array.join(KEY_ORDER_HEAD, KEY_ORDER_TAIL); + + final Class type; + final String propertyName; + + @JsonIgnore + private final String[] keyOrder; + + private Boolean required; + private T defaultValue; + + @JsonIgnore + private Function computedBy; + + @Accessors(makeFinal = true, chain = true, fluent = false) + private boolean computed; // used in descriptor, because computedBy cannot be rendered to a text string + + @Accessors(makeFinal = true, chain = true, fluent = false) + private boolean readOnly; + + @Accessors(makeFinal = true, chain = true, fluent = false) + private boolean writeOnly; + + private Function[], T[]> deferredInit; + private boolean isTotalsValidator = false; + + @JsonIgnore + private List>> asTotalLimitValidators; // TODO.impl: move to BookingItemIntegerProperty + + private Integer thresholdPercentage; // TODO.impl: move to IntegerProperty + + public final P self() { + //noinspection unchecked + return (P) this; + } + + public String unit() { + return null; + } + +protected void setDeferredInit(final Function[], T[]> function) { + this.deferredInit = function; + } + + public boolean hasDeferredInit() { + return deferredInit != null; + } + + public T[] doDeferredInit(final ValidatableProperty[] allProperties) { + return deferredInit.apply(allProperties); + } + + public P writeOnly() { + this.writeOnly = true; + optional(); + return self(); + } + + public P readOnly() { + this.readOnly = true; + optional(); + return self(); + } + + public P required() { + required = TRUE; + return self(); + } + + public ValidatableProperty optional() { + required = FALSE; + return this; + } + + public P withDefault(final T value) { + defaultValue = value; + required = FALSE; + return self(); + } + + public void deferredInit(final ValidatableProperty[] allProperties) { + } + + public P asTotalLimit() { + isTotalsValidator = true; + return self(); + } + + public P asTotalLimitFor(final String propertyName, final String propertyValue) { + if (asTotalLimitValidators == null) { + asTotalLimitValidators = new ArrayList<>(); + } + final TriFunction> validator = + (final HsBookingItemEntity entity, final IntegerProperty prop, final Integer factor) -> { + + final var total = entity.getSubBookingItems().stream() + .map(server -> server.getResources().get(propertyName)) + .filter(propertyValue::equals) + .count(); + + final long limitingValue = ofNullable(prop.getValue(entity.getResources())).orElse(0); + if (total > factor*limitingValue) { + return List.of( + prop.propertyName() + " maximum total is " + (factor*limitingValue) + ", but actual total for " + propertyName + "=" + propertyValue + " is " + total + ); + } + return emptyList(); + }; + asTotalLimitValidators.add((final HsBookingItemEntity entity) -> validator.apply(entity, (IntegerProperty)this, 1)); + return self(); + } + + public String propertyName() { + return propertyName; + } + + public boolean isTotalsValidator() { + return isTotalsValidator || asTotalLimitValidators != null; + } + + public Integer thresholdPercentage() { + return thresholdPercentage; + } + + public ValidatableProperty eachComprising(final int factor, final TriFunction> validator) { + if (asTotalLimitValidators == null) { + asTotalLimitValidators = new ArrayList<>(); + } + asTotalLimitValidators.add((final HsBookingItemEntity entity) -> validator.apply(entity, (IntegerProperty)this, factor)); + return this; + } + + public P withThreshold(final Integer percentage) { + this.thresholdPercentage = percentage; + return self(); + } + + public final List validate(final PropertiesProvider propsProvider) { + final var result = new ArrayList(); + final var props = propsProvider.directProps(); + final var propValue = props.get(propertyName); + if (propValue == null) { + if (required) { + result.add(propertyName + "' is required but missing"); + } + } + if (propValue != null){ + if ( type.isInstance(propValue)) { + //noinspection unchecked + validate(result, (T) propValue, propsProvider); + } else { + result.add(propertyName + "' is expected to be of type " + type.getSimpleName() + ", " + + "but is of type " + propValue.getClass().getSimpleName() + ""); + } + } + return result; + } + + protected abstract void validate(final List result, final T propValue, final PropertiesProvider propProvider); + + public void verifyConsistency(final Map.Entry, ?> typeDef) { + if (required == null ) { + throw new IllegalStateException(typeDef.getKey() + "[" + propertyName + "] not fully initialized, please call either .required() or .optional()" ); + } + } + + @SuppressWarnings("unchecked") + public T getValue(final Map propValues) { + return (T) Optional.ofNullable(propValues.get(propertyName)).orElse(defaultValue); + } + + protected abstract String simpleTypeName(); + + public Map toOrderedMap() { + Map sortedMap = new LinkedHashMap<>(); + sortedMap.put("type", simpleTypeName()); + + // Add entries according to the given order + for (String key : keyOrder) { + final Optional propValue = getPropertyValue(key); + propValue.filter(ValidatableProperty::isToBeRendered).ifPresent(o -> sortedMap.put(key, o)); + } + + return sortedMap; + } + + private static boolean isToBeRendered(final Object v) { + return !(v instanceof Boolean b) || b; + } + + @SneakyThrows + private Optional getPropertyValue(final String key) { + return getPropertyValue(getClass(), key); + } + + @SneakyThrows + private Optional getPropertyValue(final Class clazz, final String key) { + try { + final var field = clazz.getDeclaredField(key); + field.setAccessible(true); + return Optional.ofNullable(arrayToList(field.get(this))); + } catch (final NoSuchFieldException exc) { + if (clazz.getSuperclass() != null) { + return getPropertyValue(clazz.getSuperclass(), key); + } + throw exc; + } + } + + private Object arrayToList(final Object value) { + if (isArray(value)) { + return Arrays.stream((Object[])value).map(Object::toString).toList(); + } + return value; + } + + public List validateTotals(final HsBookingItemEntity bookingItem) { + if (asTotalLimitValidators==null) { + return emptyList(); + } + return asTotalLimitValidators.stream() + .map(v -> v.apply(bookingItem)) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .toList(); + } + + public P computedBy(final Function compute) { + this.computedBy = compute; + this.computed = true; + return self(); + } + + public T compute(final E entity) { + return computedBy.apply(entity); + } + + @Override + public String toString() { + return toOrderedMap().toString(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/test/Array.java b/src/main/java/net/hostsharing/hsadminng/mapper/Array.java similarity index 54% rename from src/test/java/net/hostsharing/hsadminng/rbac/test/Array.java rename to src/main/java/net/hostsharing/hsadminng/mapper/Array.java index c51a69bb..80970aa4 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/test/Array.java +++ b/src/main/java/net/hostsharing/hsadminng/mapper/Array.java @@ -1,10 +1,13 @@ -package net.hostsharing.hsadminng.rbac.test; +package net.hostsharing.hsadminng.mapper; + import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; +import static java.util.Arrays.asList; + /** * Java has List.of(...), Set.of(...) and Map.of(...) all with varargs parameter, * but no Array.of(...). Here it is. @@ -37,4 +40,30 @@ public class Array { return resultList.toArray(String[]::new); } + public static String[] join(final String[]... parts) { + final String[] joined = Arrays.stream(parts) + .flatMap(Arrays::stream) + .toArray(String[]::new); + return joined; + } + + public static T[] emptyArray() { + return of(); + } + + @SafeVarargs + public static T[] insertNewEntriesAfterExistingEntry(final T[] array, final T entryToFind, final T... newEntries) { + final var arrayList = new ArrayList<>(asList(array)); + final var index = arrayList.indexOf(entryToFind); + if (index < 0) { + throw new IllegalArgumentException("entry "+ entryToFind + " not found in " + Arrays.toString(array)); + } + for (int n = 0; n < newEntries.length; ++n) { + arrayList.add(index +n + 1, newEntries[n]); + } + + @SuppressWarnings("unchecked") + final var extendedArray = (T[]) java.lang.reflect.Array.newInstance(array.getClass().getComponentType(), array.length); + return arrayList.toArray(extendedArray); + } } diff --git a/src/main/java/net/hostsharing/hsadminng/mapper/PatchableMapWrapper.java b/src/main/java/net/hostsharing/hsadminng/mapper/PatchableMapWrapper.java index 4962ac8d..21153b14 100644 --- a/src/main/java/net/hostsharing/hsadminng/mapper/PatchableMapWrapper.java +++ b/src/main/java/net/hostsharing/hsadminng/mapper/PatchableMapWrapper.java @@ -53,13 +53,20 @@ public class PatchableMapWrapper implements Map { } public String toString() { - return "{ " + return "{\n" + ( keySet().stream().sorted() - .map(k -> k + ": " + get(k))) - .collect(joining(", ") + .map(k -> " \"" + k + "\": " + optionallyQuoted(get(k)))) + .collect(joining(",\n") ) - + " }"; + + "\n}\n"; + } + + private Object optionallyQuoted(final Object value) { + if ( value instanceof Number || value instanceof Boolean ) { + return value; + } + return "\"" + value + "\""; } // --- below just delegating methods -------------------------------- diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/InsertTriggerGenerator.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/InsertTriggerGenerator.java index b3c37bad..7c8b08ea 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/InsertTriggerGenerator.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacdef/InsertTriggerGenerator.java @@ -150,7 +150,7 @@ public class InsertTriggerGenerator { returns trigger language plpgsql as $$ begin - raise exception '[403] insert into ${rawSubTable} not allowed regardless of current subject, no insert permissions grated at all'; + raise exception '[403] insert into ${rawSubTable} values(%) not allowed regardless of current subject, no insert permissions granted at all', NEW; end; $$; create trigger ${rawSubTable}_insert_permission_check_tg @@ -254,8 +254,8 @@ public class InsertTriggerGenerator { private void generateInsertPermissionsChecksFooter(final StringWriter plPgSql) { plPgSql.writeLn(); plPgSql.writeLn(""" - raise exception '[403] insert into ${rawSubTable} not allowed for current subjects % (%)', - currentSubjects(), currentSubjectsUuids(); + raise exception '[403] insert into ${rawSubTable} values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); end; $$; create trigger ${rawSubTable}_insert_permission_check_tg diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramService.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramService.java index 2290c948..fd33f358 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramService.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsDiagramService.java @@ -62,6 +62,8 @@ public class RbacGrantsDiagramService { @PersistenceContext private EntityManager em; + private Map> descendantsByUuid = new HashMap<>(); + public String allGrantsToCurrentUser(final EnumSet includes) { final var graph = new LimitedHashSet(); for ( UUID subjectUuid: context.currentSubjectsUuids() ) { @@ -102,7 +104,7 @@ public class RbacGrantsDiagramService { } private void traverseGrantsFrom(final Set graph, final UUID refUuid, final EnumSet option) { - final var grants = rawGrantRepo.findByDescendantUuid(refUuid); + final var grants = findDescendantsByUuid(refUuid); grants.forEach(g -> { if (!option.contains(USERS) && g.getAscendantIdName().startsWith("user:")) { return; @@ -114,6 +116,11 @@ public class RbacGrantsDiagramService { }); } + private List findDescendantsByUuid(final UUID refUuid) { + // TODO.impl: if that UUID already got processed, do we need to return anything at all? + return descendantsByUuid.computeIfAbsent(refUuid, uuid -> rawGrantRepo.findByDescendantUuid(uuid)); + } + private String toMermaidFlowchart(final HashSet graph, final EnumSet includes) { final var entities = includes.contains(DETAILS) diff --git a/src/main/java/net/hostsharing/hsadminng/system/SystemProcess.java b/src/main/java/net/hostsharing/hsadminng/system/SystemProcess.java new file mode 100644 index 00000000..149c6019 --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/system/SystemProcess.java @@ -0,0 +1,57 @@ +package net.hostsharing.hsadminng.system; + +import lombok.Getter; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; + +public class SystemProcess { + private final ProcessBuilder processBuilder; + + @Getter + private String stdOut; + @Getter + private String stdErr; + + public SystemProcess(final String... command) { + this.processBuilder = new ProcessBuilder(command); + } + + public int execute() throws IOException, InterruptedException { + final var process = processBuilder.start(); + stdOut = fetchOutput(process.getInputStream()); // yeah, twisted ProcessBuilder API + stdErr = fetchOutput(process.getErrorStream()); + return process.waitFor(); + } + + public int execute(final String input) throws IOException, InterruptedException { + final var process = processBuilder.start(); + feedInput(input, process); + stdOut = fetchOutput(process.getInputStream()); // yeah, twisted ProcessBuilder API + stdErr = fetchOutput(process.getErrorStream()); + return process.waitFor(); + } + + private static void feedInput(final String input, final Process process) throws IOException { + try ( + final OutputStreamWriter stdIn = new OutputStreamWriter(process.getOutputStream()); // yeah, twisted ProcessBuilder API + final BufferedWriter writer = new BufferedWriter(stdIn)) { + writer.write(input); + writer.flush(); + } + } + + private static String fetchOutput(final InputStream inputStream) throws IOException { + final var output = new StringBuilder(); + try (final BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + for (String line; (line = reader.readLine()) != null; ) { + output.append(line).append(System.lineSeparator()); + } + } + return output.toString(); + } +} diff --git a/src/main/resources/api-definition/hs-booking/api-mappings.yaml b/src/main/resources/api-definition/hs-booking/api-mappings.yaml index e16861f0..18f34c1f 100644 --- a/src/main/resources/api-definition/hs-booking/api-mappings.yaml +++ b/src/main/resources/api-definition/hs-booking/api-mappings.yaml @@ -13,5 +13,7 @@ map: - type: string:uuid => java.util.UUID paths: + /api/hs/booking/projects/{bookingProjectUuid}: + null: org.openapitools.jackson.nullable.JsonNullable /api/hs/booking/items/{bookingItemUuid}: null: org.openapitools.jackson.nullable.JsonNullable diff --git a/src/main/resources/api-definition/hs-booking/hs-booking-item-schemas.yaml b/src/main/resources/api-definition/hs-booking/hs-booking-item-schemas.yaml index 25add552..b18c7356 100644 --- a/src/main/resources/api-definition/hs-booking/hs-booking-item-schemas.yaml +++ b/src/main/resources/api-definition/hs-booking/hs-booking-item-schemas.yaml @@ -51,7 +51,7 @@ components: HsBookingItemInsert: type: object properties: - debitorUuid: + projectUuid: type: string format: uuid nullable: false @@ -62,10 +62,6 @@ components: minLength: 3 maxLength: 80 nullable: false - validFrom: - type: string - format: date - nullable: false validTo: type: string format: date @@ -74,7 +70,7 @@ components: $ref: '#/components/schemas/BookingResources' required: - caption - - debitorUuid + - projectUuid - validFrom - resources additionalProperties: false diff --git a/src/main/resources/api-definition/hs-booking/hs-booking-items.yaml b/src/main/resources/api-definition/hs-booking/hs-booking-items.yaml index e869af21..40a3d010 100644 --- a/src/main/resources/api-definition/hs-booking/hs-booking-items.yaml +++ b/src/main/resources/api-definition/hs-booking/hs-booking-items.yaml @@ -1,19 +1,19 @@ get: - summary: Returns a list of all booking items for a specified debitor. - description: Returns the list of all booking items for a specified debitor which are visible to the current user or any of it's assumed roles. + summary: Returns a list of all booking items for a specified project. + description: Returns the list of all booking items for a specified project which are visible to the current user or any of it's assumed roles. tags: - hs-booking-items - operationId: listBookingItemsByDebitorUuid + operationId: listBookingItemsByProjectUuid parameters: - $ref: 'auth.yaml#/components/parameters/currentUser' - $ref: 'auth.yaml#/components/parameters/assumedRoles' - - name: debitorUuid + - name: projectUuid in: query required: true schema: type: string format: uuid - description: The UUID of the debitor, whose booking items are to be listed. + description: The UUID of the project, whose booking items are to be listed. responses: "200": description: OK diff --git a/src/main/resources/api-definition/hs-booking/hs-booking-project-schemas.yaml b/src/main/resources/api-definition/hs-booking/hs-booking-project-schemas.yaml new file mode 100644 index 00000000..de95203d --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/hs-booking-project-schemas.yaml @@ -0,0 +1,40 @@ + +components: + + schemas: + + HsBookingProject: + type: object + properties: + uuid: + type: string + format: uuid + caption: + type: string + required: + - uuid + - caption + + HsBookingProjectPatch: + type: object + properties: + caption: + type: string + nullable: true + + HsBookingProjectInsert: + type: object + properties: + debitorUuid: + type: string + format: uuid + nullable: false + caption: + type: string + minLength: 3 + maxLength: 80 + nullable: false + required: + - debitorUuid + - caption + additionalProperties: false diff --git a/src/main/resources/api-definition/hs-booking/hs-booking-projects-with-uuid.yaml b/src/main/resources/api-definition/hs-booking/hs-booking-projects-with-uuid.yaml new file mode 100644 index 00000000..085205a7 --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/hs-booking-projects-with-uuid.yaml @@ -0,0 +1,83 @@ +get: + tags: + - hs-booking-projects + description: 'Fetch a single booking project its uuid, if visible for the current subject.' + operationId: getBookingProjectByUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: bookingProjectUuid + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the booking project to fetch. + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'hs-booking-project-schemas.yaml#/components/schemas/HsBookingProject' + + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +patch: + tags: + - hs-booking-projects + description: 'Updates a single booking project identified by its uuid, if permitted for the current subject.' + operationId: patchBookingProject + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: bookingProjectUuid + in: path + required: true + schema: + type: string + format: uuid + requestBody: + content: + 'application/json': + schema: + $ref: 'hs-booking-project-schemas.yaml#/components/schemas/HsBookingProjectPatch' + responses: + "200": + description: OK + content: + 'application/json': + schema: + $ref: 'hs-booking-project-schemas.yaml#/components/schemas/HsBookingProject' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +delete: + tags: + - hs-booking-projects + description: 'Delete a single booking project identified by its uuid, if permitted for the current subject.' + operationId: deleteBookingIemByUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: bookingProjectUuid + in: path + required: true + schema: + type: string + format: uuid + description: UUID of the booking project 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' diff --git a/src/main/resources/api-definition/hs-booking/hs-booking-projects.yaml b/src/main/resources/api-definition/hs-booking/hs-booking-projects.yaml new file mode 100644 index 00000000..bccb7443 --- /dev/null +++ b/src/main/resources/api-definition/hs-booking/hs-booking-projects.yaml @@ -0,0 +1,58 @@ +get: + summary: Returns a list of all booking projects for a specified debitor. + description: Returns the list of all booking projects for a specified debitor which are visible to the current user or any of it's assumed roles. + tags: + - hs-booking-projects + operationId: listBookingProjectsByDebitorUuid + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + - name: debitorUuid + in: query + required: true + schema: + type: string + format: uuid + description: The UUID of the debitor, whose booking projects are to be listed. + responses: + "200": + description: OK + content: + 'application/json': + schema: + type: array + items: + $ref: 'hs-booking-project-schemas.yaml#/components/schemas/HsBookingProject' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + +post: + summary: Adds a new project as a container for booking items. + tags: + - hs-booking-projects + operationId: addBookingProject + parameters: + - $ref: 'auth.yaml#/components/parameters/currentUser' + - $ref: 'auth.yaml#/components/parameters/assumedRoles' + requestBody: + description: A JSON object describing the new booking project. + required: true + content: + application/json: + schema: + $ref: 'hs-booking-project-schemas.yaml#/components/schemas/HsBookingProjectInsert' + responses: + "201": + description: Created + content: + 'application/json': + schema: + $ref: 'hs-booking-project-schemas.yaml#/components/schemas/HsBookingProject' + "401": + $ref: 'error-responses.yaml#/components/responses/Unauthorized' + "403": + $ref: 'error-responses.yaml#/components/responses/Forbidden' + "409": + $ref: 'error-responses.yaml#/components/responses/Conflict' diff --git a/src/main/resources/api-definition/hs-booking/hs-booking.yaml b/src/main/resources/api-definition/hs-booking/hs-booking.yaml index d6a67058..6faaf47c 100644 --- a/src/main/resources/api-definition/hs-booking/hs-booking.yaml +++ b/src/main/resources/api-definition/hs-booking/hs-booking.yaml @@ -8,6 +8,15 @@ servers: paths: + # Projects + + /api/hs/booking/projects: + $ref: "hs-booking-projects.yaml" + + /api/hs/booking/projects/{bookingProjectUuid}: + $ref: "hs-booking-projects-with-uuid.yaml" + + # Items /api/hs/booking/items: diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-schemas.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-schemas.yaml index 7390c3c8..f4a25607 100644 --- a/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-schemas.yaml +++ b/src/main/resources/api-definition/hs-hosting/hs-hosting-asset-schemas.yaml @@ -11,6 +11,10 @@ components: - MANAGED_WEBSPACE - UNIX_USER - DOMAIN_SETUP + - DOMAIN_DNS_SETUP + - DOMAIN_HTTP_SETUP + - DOMAIN_SMTP_SETUP + - DOMAIN_MBOX_SETUP - EMAIL_ALIAS - EMAIL_ADDRESS - PGSQL_USER @@ -30,6 +34,8 @@ components: type: string caption: type: string + alarmContact: + $ref: '../hs-office/hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact' config: $ref: '#/components/schemas/HsHostingAssetConfiguration' required: @@ -44,6 +50,10 @@ components: caption: type: string nullable: true + alarmContactUuid: + type: string + format: uuid + nullable: true config: $ref: '#/components/schemas/HsHostingAssetConfiguration' @@ -70,6 +80,10 @@ components: minLength: 3 maxLength: 80 nullable: false + alarmContactUuid: + type: string + format: uuid + nullable: true config: $ref: '#/components/schemas/HsHostingAssetConfiguration' required: diff --git a/src/main/resources/api-definition/hs-hosting/hs-hosting-assets.yaml b/src/main/resources/api-definition/hs-hosting/hs-hosting-assets.yaml index a08a36a1..8a208c68 100644 --- a/src/main/resources/api-definition/hs-hosting/hs-hosting-assets.yaml +++ b/src/main/resources/api-definition/hs-hosting/hs-hosting-assets.yaml @@ -7,13 +7,13 @@ get: parameters: - $ref: 'auth.yaml#/components/parameters/currentUser' - $ref: 'auth.yaml#/components/parameters/assumedRoles' - - name: debitorUuid + - name: projectUuid in: query required: false schema: type: string format: uuid - description: The UUID of the debitor, whose hosting assets are to be listed. + description: The UUID of the project, whose hosting assets are to be listed. - name: parentAssetUuid in: query required: false diff --git a/src/main/resources/db/changelog/0-basis/010-context.sql b/src/main/resources/db/changelog/0-basis/010-context.sql index 8ea73f45..25c6c48c 100644 --- a/src/main/resources/db/changelog/0-basis/010-context.sql +++ b/src/main/resources/db/changelog/0-basis/010-context.sql @@ -149,7 +149,7 @@ create or replace function cleanIdentifier(rawIdentifier varchar) declare cleanIdentifier varchar; begin - cleanIdentifier := regexp_replace(rawIdentifier, '[^A-Za-z0-9\-._]+', '', 'g'); + cleanIdentifier := regexp_replace(rawIdentifier, '[^A-Za-z0-9\-._|]+', '', 'g'); return cleanIdentifier; end; $$; diff --git a/src/main/resources/db/changelog/1-rbac/1058-rbac-generators.sql b/src/main/resources/db/changelog/1-rbac/1058-rbac-generators.sql index 958d3afe..016b8f89 100644 --- a/src/main/resources/db/changelog/1-rbac/1058-rbac-generators.sql +++ b/src/main/resources/db/changelog/1-rbac/1058-rbac-generators.sql @@ -118,10 +118,13 @@ begin sql = format($sql$ create or replace function %1$sUuidByIdName(givenIdName varchar) returns uuid - language sql - strict as $f$ - select uuid from %1$s_iv iv where iv.idName = givenIdName; - $f$; + language plpgsql as $f$ + declare + singleMatch uuid; + begin + select uuid into strict singleMatch from %1$s_iv iv where iv.idName = givenIdName; + return singleMatch; + end; $f$; $sql$, targetTable); execute sql; diff --git a/src/main/resources/db/changelog/6-hs-booking/610-booking-debitor/6100-hs-booking-debitor.sql b/src/main/resources/db/changelog/6-hs-booking/610-booking-debitor/6100-hs-booking-debitor.sql new file mode 100644 index 00000000..c9dc8287 --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/610-booking-debitor/6100-hs-booking-debitor.sql @@ -0,0 +1,17 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset hs-booking-debitor-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create view hs_booking_debitor_rv as + select debitor.uuid, + debitor.version, + (partner.partnerNumber::varchar || debitor.debitorNumberSuffix)::numeric as debitorNumber, + debitor.defaultPrefix + from hs_office_debitor_rv debitor + -- RBAC for debitor is sufficient, for faster access we are bypassing RBAC for the join tables + join hs_office_relation debitorRel on debitor.debitorReluUid=debitorRel.uuid + join hs_office_relation partnerRel on partnerRel.holderUuid=debitorRel.anchorUuid + join hs_office_partner partner on partner.partnerReluUid=partnerRel.uuid; +--// diff --git a/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6200-hs-booking-project.sql b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6200-hs-booking-project.sql new file mode 100644 index 00000000..41fc650a --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6200-hs-booking-project.sql @@ -0,0 +1,22 @@ +--liquibase formatted sql + +-- ============================================================================ +--changeset booking-project-MAIN-TABLE:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +create table if not exists hs_booking_project +( + uuid uuid unique references RbacObject (uuid), + version int not null default 0, + debitorUuid uuid not null references hs_office_debitor(uuid), + caption varchar(80) not null +); +--// + + +-- ============================================================================ +--changeset hs-booking-project-MAIN-TABLE-JOURNAL:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call create_journal('hs_booking_project'); +--// diff --git a/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.md b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.md new file mode 100644 index 00000000..270908a8 --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.md @@ -0,0 +1,63 @@ +### rbac project + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph debitorRel["`**debitorRel**`"] + direction TB + style debitorRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph debitorRel:roles[ ] + style debitorRel:roles fill:#99bcdb,stroke:white + + role:debitorRel:OWNER[[debitorRel:OWNER]] + role:debitorRel:ADMIN[[debitorRel:ADMIN]] + role:debitorRel:AGENT[[debitorRel:AGENT]] + role:debitorRel:TENANT[[debitorRel:TENANT]] + end +end + +subgraph project["`**project**`"] + direction TB + style project fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph project:roles[ ] + style project:roles fill:#dd4901,stroke:white + + role:project:OWNER[[project:OWNER]] + role:project:ADMIN[[project:ADMIN]] + role:project:AGENT[[project:AGENT]] + role:project:TENANT[[project:TENANT]] + end + + subgraph project:permissions[ ] + style project:permissions fill:#dd4901,stroke:white + + perm:project:INSERT{{project:INSERT}} + perm:project:DELETE{{project:DELETE}} + perm:project:UPDATE{{project:UPDATE}} + perm:project:SELECT{{project:SELECT}} + end +end + +%% granting roles to roles +role:global:ADMIN -.-> role:debitorRel:OWNER +role:debitorRel:OWNER -.-> role:debitorRel:ADMIN +role:debitorRel:ADMIN -.-> role:debitorRel:AGENT +role:debitorRel:AGENT -.-> role:debitorRel:TENANT +role:debitorRel:AGENT ==> role:project:OWNER +role:project:OWNER ==> role:project:ADMIN +role:project:ADMIN ==> role:project:AGENT +role:project:AGENT ==> role:project:TENANT +role:project:TENANT ==> role:debitorRel:TENANT + +%% granting permissions to roles +role:debitorRel:ADMIN ==> perm:project:INSERT +role:global:ADMIN ==> perm:project:DELETE +role:project:ADMIN ==> perm:project:UPDATE +role:project:TENANT ==> perm:project:SELECT + +``` diff --git a/src/main/resources/db/changelog/6-hs-booking/601-booking-item/6013-hs-booking-item-rbac.sql b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql similarity index 59% rename from src/main/resources/db/changelog/6-hs-booking/601-booking-item/6013-hs-booking-item-rbac.sql rename to src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql index e26edbbb..e0e0a9b7 100644 --- a/src/main/resources/db/changelog/6-hs-booking/601-booking-item/6013-hs-booking-item-rbac.sql +++ b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql @@ -3,29 +3,29 @@ -- ============================================================================ ---changeset hs-booking-item-rbac-OBJECT:1 endDelimiter:--// +--changeset hs-booking-project-rbac-OBJECT:1 endDelimiter:--// -- ---------------------------------------------------------------------------- -call generateRelatedRbacObject('hs_booking_item'); +call generateRelatedRbacObject('hs_booking_project'); --// -- ============================================================================ ---changeset hs-booking-item-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +--changeset hs-booking-project-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// -- ---------------------------------------------------------------------------- -call generateRbacRoleDescriptors('hsBookingItem', 'hs_booking_item'); +call generateRbacRoleDescriptors('hsBookingProject', 'hs_booking_project'); --// -- ============================================================================ ---changeset hs-booking-item-rbac-insert-trigger:1 endDelimiter:--// +--changeset hs-booking-project-rbac-insert-trigger:1 endDelimiter:--// -- ---------------------------------------------------------------------------- /* Creates the roles, grants and permission for the AFTER INSERT TRIGGER. */ -create or replace procedure buildRbacSystemForHsBookingItem( - NEW hs_booking_item +create or replace procedure buildRbacSystemForHsBookingProject( + NEW hs_booking_project ) language plpgsql as $$ @@ -48,27 +48,25 @@ begin perform createRoleWithGrants( - hsBookingItemOWNER(NEW), + hsBookingProjectOWNER(NEW), incomingSuperRoles => array[hsOfficeRelationAGENT(newDebitorRel)] ); perform createRoleWithGrants( - hsBookingItemADMIN(NEW), + hsBookingProjectADMIN(NEW), permissions => array['UPDATE'], - incomingSuperRoles => array[ - hsBookingItemOWNER(NEW), - hsOfficeRelationAGENT(newDebitorRel)] + incomingSuperRoles => array[hsBookingProjectOWNER(NEW)] ); perform createRoleWithGrants( - hsBookingItemAGENT(NEW), - incomingSuperRoles => array[hsBookingItemADMIN(NEW)] + hsBookingProjectAGENT(NEW), + incomingSuperRoles => array[hsBookingProjectADMIN(NEW)] ); perform createRoleWithGrants( - hsBookingItemTENANT(NEW), + hsBookingProjectTENANT(NEW), permissions => array['SELECT'], - incomingSuperRoles => array[hsBookingItemAGENT(NEW)], + incomingSuperRoles => array[hsBookingProjectAGENT(NEW)], outgoingSubRoles => array[hsOfficeRelationTENANT(newDebitorRel)] ); @@ -78,81 +76,81 @@ begin end; $$; /* - AFTER INSERT TRIGGER to create the role+grant structure for a new hs_booking_item row. + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_booking_project row. */ -create or replace function insertTriggerForHsBookingItem_tf() +create or replace function insertTriggerForHsBookingProject_tf() returns trigger language plpgsql strict as $$ begin - call buildRbacSystemForHsBookingItem(NEW); + call buildRbacSystemForHsBookingProject(NEW); return NEW; end; $$; -create trigger insertTriggerForHsBookingItem_tg - after insert on hs_booking_item +create trigger insertTriggerForHsBookingProject_tg + after insert on hs_booking_project for each row -execute procedure insertTriggerForHsBookingItem_tf(); +execute procedure insertTriggerForHsBookingProject_tf(); --// -- ============================================================================ ---changeset hs-booking-item-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +--changeset hs-booking-project-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// -- ---------------------------------------------------------------------------- -- granting INSERT permission to hs_office_relation ---------------------------- /* - Grants INSERT INTO hs_booking_item permissions to specified role of pre-existing hs_office_relation rows. + Grants INSERT INTO hs_booking_project permissions to specified role of pre-existing hs_office_relation rows. */ do language plpgsql $$ declare row hs_office_relation; begin - call defineContext('create INSERT INTO hs_booking_item permissions for pre-exising hs_office_relation rows'); + call defineContext('create INSERT INTO hs_booking_project permissions for pre-exising hs_office_relation rows'); FOR row IN SELECT * FROM hs_office_relation WHERE type = 'DEBITOR' LOOP call grantPermissionToRole( - createPermission(row.uuid, 'INSERT', 'hs_booking_item'), + createPermission(row.uuid, 'INSERT', 'hs_booking_project'), hsOfficeRelationADMIN(row)); END LOOP; end; $$; /** - Grants hs_booking_item INSERT permission to specified role of new hs_office_relation rows. + Grants hs_booking_project INSERT permission to specified role of new hs_office_relation rows. */ -create or replace function new_hs_booking_item_grants_insert_to_hs_office_relation_tf() +create or replace function new_hs_booking_project_grants_insert_to_hs_office_relation_tf() returns trigger language plpgsql strict as $$ begin if NEW.type = 'DEBITOR' then call grantPermissionToRole( - createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + createPermission(NEW.uuid, 'INSERT', 'hs_booking_project'), hsOfficeRelationADMIN(NEW)); end if; return NEW; end; $$; -- z_... is to put it at the end of after insert triggers, to make sure the roles exist -create trigger z_new_hs_booking_item_grants_insert_to_hs_office_relation_tg +create trigger z_new_hs_booking_project_grants_insert_to_hs_office_relation_tg after insert on hs_office_relation for each row -execute procedure new_hs_booking_item_grants_insert_to_hs_office_relation_tf(); +execute procedure new_hs_booking_project_grants_insert_to_hs_office_relation_tf(); -- ============================================================================ ---changeset hs_booking_item-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +--changeset hs_booking_project-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// -- ---------------------------------------------------------------------------- /** - Checks if the user respectively the assumed roles are allowed to insert a row to hs_booking_item. + Checks if the user respectively the assumed roles are allowed to insert a row to hs_booking_project. */ -create or replace function hs_booking_item_insert_permission_check_tf() +create or replace function hs_booking_project_insert_permission_check_tf() returns trigger language plpgsql as $$ declare @@ -164,47 +162,45 @@ begin JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid WHERE debitor.uuid = NEW.debitorUuid ); - assert superObjectUuid is not null, 'object uuid fetched depending on hs_booking_item.debitorUuid must not be null, also check fetchSql in RBAC DSL'; - if hasInsertPermission(superObjectUuid, 'hs_booking_item') then + assert superObjectUuid is not null, 'object uuid fetched depending on hs_booking_project.debitorUuid must not be null, also check fetchSql in RBAC DSL'; + if hasInsertPermission(superObjectUuid, 'hs_booking_project') then return NEW; end if; - raise exception '[403] insert into hs_booking_item not allowed for current subjects % (%)', - currentSubjects(), currentSubjectsUuids(); + raise exception '[403] insert into hs_booking_project values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); end; $$; -create trigger hs_booking_item_insert_permission_check_tg - before insert on hs_booking_item +create trigger hs_booking_project_insert_permission_check_tg + before insert on hs_booking_project for each row - execute procedure hs_booking_item_insert_permission_check_tf(); + execute procedure hs_booking_project_insert_permission_check_tf(); --// -- ============================================================================ ---changeset hs-booking-item-rbac-IDENTITY-VIEW:1 endDelimiter:--// +--changeset hs-booking-project-rbac-IDENTITY-VIEW:1 endDelimiter:--// -- ---------------------------------------------------------------------------- -call generateRbacIdentityViewFromQuery('hs_booking_item', +call generateRbacIdentityViewFromQuery('hs_booking_project', $idName$ - SELECT bookingItem.uuid as uuid, debitorIV.idName || '-' || cleanIdentifier(bookingItem.caption) as idName - FROM hs_booking_item bookingItem - JOIN hs_office_debitor_iv debitorIV ON debitorIV.uuid = bookingItem.debitorUuid + SELECT bookingProject.uuid as uuid, debitorIV.idName || '-' || cleanIdentifier(bookingProject.caption) as idName + FROM hs_booking_project bookingProject + JOIN hs_office_debitor_iv debitorIV ON debitorIV.uuid = bookingProject.debitorUuid $idName$); --// -- ============================================================================ ---changeset hs-booking-item-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +--changeset hs-booking-project-rbac-RESTRICTED-VIEW:1 endDelimiter:--// -- ---------------------------------------------------------------------------- -call generateRbacRestrictedView('hs_booking_item', +call generateRbacRestrictedView('hs_booking_project', $orderBy$ - validity + caption $orderBy$, $updates$ version = new.version, - caption = new.caption, - validity = new.validity, - resources = new.resources + caption = new.caption $updates$); --// diff --git a/src/main/resources/db/changelog/6-hs-booking/601-booking-item/6018-hs-booking-item-test-data.sql b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6208-hs-booking-project-test-data.sql similarity index 51% rename from src/main/resources/db/changelog/6-hs-booking/601-booking-item/6018-hs-booking-item-test-data.sql rename to src/main/resources/db/changelog/6-hs-booking/620-booking-project/6208-hs-booking-project-test-data.sql index 88ada16f..5ebae299 100644 --- a/src/main/resources/db/changelog/6-hs-booking/601-booking-item/6018-hs-booking-item-test-data.sql +++ b/src/main/resources/db/changelog/6-hs-booking/620-booking-project/6208-hs-booking-project-test-data.sql @@ -2,13 +2,13 @@ -- ============================================================================ ---changeset hs-booking-item-TEST-DATA-GENERATOR:1 endDelimiter:--// +--changeset hs-booking-project-TEST-DATA-GENERATOR:1 endDelimiter:--// -- ---------------------------------------------------------------------------- /* - Creates a single hs_booking_item test record. + Creates a single hs_booking_project test record. */ -create or replace procedure createHsBookingItemTransactionTestData( +create or replace procedure createHsBookingProjectTransactionTestData( givenPartnerNumber numeric, givenDebitorSuffix char(2) ) @@ -17,7 +17,7 @@ declare currentTask varchar; relatedDebitor hs_office_debitor; begin - currentTask := 'creating booking-item test-data ' || givenPartnerNumber::text || givenDebitorSuffix; + currentTask := 'creating booking-project test-data ' || givenPartnerNumber::text || givenDebitorSuffix; call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global:ADMIN'); execute format('set local hsadminng.currentTask to %L', currentTask); @@ -28,26 +28,24 @@ begin join hs_office_partner partner on partner.partnerRelUuid = partnerRel.uuid where partner.partnerNumber = givenPartnerNumber and debitor.debitorNumberSuffix = givenDebitorSuffix; - raise notice 'creating test booking-item: %', givenPartnerNumber::text || givenDebitorSuffix::text; + raise notice 'creating test booking-project: %', givenDebitorSuffix::text; raise notice '- using debitor (%): %', relatedDebitor.uuid, relatedDebitor; insert - into hs_booking_item (uuid, debitoruuid, type, caption, validity, resources) - values (uuid_generate_v4(), relatedDebitor.uuid, 'MANAGED_SERVER', 'some ManagedServer', daterange('20221001', null, '[]'), '{ "CPUs": 2, "RAM": 8, "SDD": 512, "Traffic": 42 }'::jsonb), - (uuid_generate_v4(), relatedDebitor.uuid, 'CLOUD_SERVER', 'some CloudServer', daterange('20230115', '20240415', '[)'), '{ "CPUs": 2, "RAM": 4, "HDD": 1024, "Traffic": 42 }'::jsonb), - (uuid_generate_v4(), relatedDebitor.uuid, 'PRIVATE_CLOUD', 'some PrivateCloud', daterange('20240401', null, '[]'), '{ "CPUs": 10, "SDD": 10240, "HDD": 10240, "Traffic": 42 }'::jsonb); + into hs_booking_project (uuid, debitoruuid, caption) + values (uuid_generate_v4(), relatedDebitor.uuid, 'D-' || givenPartnerNumber::text || givenDebitorSuffix || ' default project'); end; $$; --// -- ============================================================================ ---changeset hs-booking-item-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// +--changeset hs-booking-project-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// -- ---------------------------------------------------------------------------- do language plpgsql $$ begin - call createHsBookingItemTransactionTestData(10001, '11'); - call createHsBookingItemTransactionTestData(10002, '12'); - call createHsBookingItemTransactionTestData(10003, '13'); + call createHsBookingProjectTransactionTestData(10001, '11'); + call createHsBookingProjectTransactionTestData(10002, '12'); + call createHsBookingProjectTransactionTestData(10003, '13'); end; $$; --// diff --git a/src/main/resources/db/changelog/6-hs-booking/601-booking-item/6010-hs-booking-item.sql b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6200-hs-booking-item.sql similarity index 75% rename from src/main/resources/db/changelog/6-hs-booking/601-booking-item/6010-hs-booking-item.sql rename to src/main/resources/db/changelog/6-hs-booking/630-booking-item/6200-hs-booking-item.sql index d63e317e..6c76c29f 100644 --- a/src/main/resources/db/changelog/6-hs-booking/601-booking-item/6010-hs-booking-item.sql +++ b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6200-hs-booking-item.sql @@ -17,11 +17,15 @@ create table if not exists hs_booking_item ( uuid uuid unique references RbacObject (uuid), version int not null default 0, - debitorUuid uuid not null references hs_office_debitor(uuid), + projectUuid uuid null references hs_booking_project(uuid), type HsBookingItemType not null, + parentItemUuid uuid null references hs_booking_item(uuid) initially deferred, validity daterange not null, caption varchar(80) not null, - resources jsonb not null + resources jsonb not null, + + constraint chk_hs_booking_item_has_project_or_parent_asset + check (projectUuid is not null or parentItemUuid is not null) ); --// diff --git a/src/main/resources/db/changelog/6-hs-booking/601-booking-item/6013-hs-booking-item-rbac.md b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.md similarity index 63% rename from src/main/resources/db/changelog/6-hs-booking/601-booking-item/6013-hs-booking-item-rbac.md rename to src/main/resources/db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.md index 7ba21f5c..4775616f 100644 --- a/src/main/resources/db/changelog/6-hs-booking/601-booking-item/6013-hs-booking-item-rbac.md +++ b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.md @@ -29,35 +29,34 @@ subgraph bookingItem["`**bookingItem**`"] end end -subgraph debitorRel["`**debitorRel**`"] +subgraph project["`**project**`"] direction TB - style debitorRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px + style project fill:#99bcdb,stroke:#274d6e,stroke-width:8px - subgraph debitorRel:roles[ ] - style debitorRel:roles fill:#99bcdb,stroke:white + subgraph project:roles[ ] + style project:roles fill:#99bcdb,stroke:white - role:debitorRel:OWNER[[debitorRel:OWNER]] - role:debitorRel:ADMIN[[debitorRel:ADMIN]] - role:debitorRel:AGENT[[debitorRel:AGENT]] - role:debitorRel:TENANT[[debitorRel:TENANT]] + role:project:OWNER[[project:OWNER]] + role:project:ADMIN[[project:ADMIN]] + role:project:AGENT[[project:AGENT]] + role:project:TENANT[[project:TENANT]] end end %% granting roles to roles -role:global:ADMIN -.-> role:debitorRel:OWNER -role:debitorRel:OWNER -.-> role:debitorRel:ADMIN -role:debitorRel:ADMIN -.-> role:debitorRel:AGENT -role:debitorRel:AGENT -.-> role:debitorRel:TENANT -role:debitorRel:AGENT ==> role:bookingItem:OWNER +role:project:OWNER -.-> role:project:ADMIN +role:project:ADMIN -.-> role:project:AGENT +role:project:AGENT -.-> role:project:TENANT +role:project:AGENT ==> role:bookingItem:OWNER role:bookingItem:OWNER ==> role:bookingItem:ADMIN -role:debitorRel:AGENT ==> role:bookingItem:ADMIN role:bookingItem:ADMIN ==> role:bookingItem:AGENT role:bookingItem:AGENT ==> role:bookingItem:TENANT -role:bookingItem:TENANT ==> role:debitorRel:TENANT +role:bookingItem:TENANT ==> role:project:TENANT %% granting permissions to roles -role:debitorRel:ADMIN ==> perm:bookingItem:INSERT +role:global:ADMIN ==> perm:bookingItem:INSERT role:global:ADMIN ==> perm:bookingItem:DELETE +role:project:ADMIN ==> perm:bookingItem:INSERT role:bookingItem:ADMIN ==> perm:bookingItem:UPDATE role:bookingItem:TENANT ==> perm:bookingItem:SELECT diff --git a/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.sql b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.sql new file mode 100644 index 00000000..bcd6523e --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.sql @@ -0,0 +1,277 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-booking-item-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_booking_item'); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsBookingItem', 'hs_booking_item'); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsBookingItem( + NEW hs_booking_item +) + language plpgsql as $$ + +declare + newProject hs_booking_project; + newParentItem hs_booking_item; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM hs_booking_project WHERE uuid = NEW.projectUuid INTO newProject; + + SELECT * FROM hs_booking_item WHERE uuid = NEW.parentItemUuid INTO newParentItem; + + perform createRoleWithGrants( + hsBookingItemOWNER(NEW), + incomingSuperRoles => array[ + hsBookingItemAGENT(newParentItem), + hsBookingProjectAGENT(newProject)] + ); + + perform createRoleWithGrants( + hsBookingItemADMIN(NEW), + permissions => array['UPDATE'], + incomingSuperRoles => array[hsBookingItemOWNER(NEW)] + ); + + perform createRoleWithGrants( + hsBookingItemAGENT(NEW), + incomingSuperRoles => array[hsBookingItemADMIN(NEW)] + ); + + perform createRoleWithGrants( + hsBookingItemTENANT(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[hsBookingItemAGENT(NEW)], + outgoingSubRoles => array[ + hsBookingItemTENANT(newParentItem), + hsBookingProjectTENANT(newProject)] + ); + + + + call grantPermissionToRole(createPermission(NEW.uuid, 'DELETE'), globalAdmin()); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_booking_item row. + */ + +create or replace function insertTriggerForHsBookingItem_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsBookingItem(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsBookingItem_tg + after insert on hs_booking_item + for each row +execute procedure insertTriggerForHsBookingItem_tf(); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to global ---------------------------- + +/* + Grants INSERT INTO hs_booking_item permissions to specified role of pre-existing global rows. + */ +do language plpgsql $$ + declare + row global; + begin + call defineContext('create INSERT INTO hs_booking_item permissions for pre-exising global rows'); + + FOR row IN SELECT * FROM global + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_booking_item'), + globalADMIN()); + END LOOP; + end; +$$; + +/** + Grants hs_booking_item INSERT permission to specified role of new global rows. +*/ +create or replace function new_hs_booking_item_grants_insert_to_global_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + globalADMIN()); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_item_grants_insert_to_global_tg + after insert on global + for each row +execute procedure new_hs_booking_item_grants_insert_to_global_tf(); + +-- granting INSERT permission to hs_booking_project ---------------------------- + +/* + Grants INSERT INTO hs_booking_item permissions to specified role of pre-existing hs_booking_project rows. + */ +do language plpgsql $$ + declare + row hs_booking_project; + begin + call defineContext('create INSERT INTO hs_booking_item permissions for pre-exising hs_booking_project rows'); + + FOR row IN SELECT * FROM hs_booking_project + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_booking_item'), + hsBookingProjectADMIN(row)); + END LOOP; + end; +$$; + +/** + Grants hs_booking_item INSERT permission to specified role of new hs_booking_project rows. +*/ +create or replace function new_hs_booking_item_grants_insert_to_hs_booking_project_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + hsBookingProjectADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_item_grants_insert_to_hs_booking_project_tg + after insert on hs_booking_project + for each row +execute procedure new_hs_booking_item_grants_insert_to_hs_booking_project_tf(); + +-- granting INSERT permission to hs_booking_item ---------------------------- + +-- Granting INSERT INTO hs_hosting_asset permissions to specified role of pre-existing hs_hosting_asset rows slipped, +-- because there cannot yet be any pre-existing rows in the same table yet. + +/** + Grants hs_booking_item INSERT permission to specified role of new hs_booking_item rows. +*/ +create or replace function new_hs_booking_item_grants_insert_to_hs_booking_item_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + hsBookingItemADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_item_grants_insert_to_hs_booking_item_tg + after insert on hs_booking_item + for each row +execute procedure new_hs_booking_item_grants_insert_to_hs_booking_item_tf(); + + +-- ============================================================================ +--changeset hs_booking_item-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_booking_item. +*/ +create or replace function hs_booking_item_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT INSERT if global ADMIN + if isGlobalAdmin() then + return NEW; + end if; + -- check INSERT permission via direct foreign key: NEW.projectUuid + if hasInsertPermission(NEW.projectUuid, 'hs_booking_item') then + return NEW; + end if; + -- check INSERT permission via direct foreign key: NEW.parentItemUuid + if hasInsertPermission(NEW.parentItemUuid, 'hs_booking_item') then + return NEW; + end if; + + raise exception '[403] insert into hs_booking_item values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_booking_item_insert_permission_check_tg + before insert on hs_booking_item + for each row + execute procedure hs_booking_item_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('hs_booking_item', + $idName$ + caption + $idName$); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_booking_item', + $orderBy$ + validity + $orderBy$, + $updates$ + version = new.version, + caption = new.caption, + validity = new.validity, + resources = new.resources + $updates$); +--// + diff --git a/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6208-hs-booking-item-test-data.sql b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6208-hs-booking-item-test-data.sql new file mode 100644 index 00000000..3f007ab8 --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6208-hs-booking-item-test-data.sql @@ -0,0 +1,58 @@ +--liquibase formatted sql + + +-- ============================================================================ +--changeset hs-booking-item-TEST-DATA-GENERATOR:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates a single hs_booking_item test record. + */ +create or replace procedure createHsBookingItemTransactionTestData( + givenPartnerNumber numeric, + givenDebitorSuffix char(2) + ) + language plpgsql as $$ +declare + currentTask varchar; + relatedProject hs_booking_project; + privateCloudUuid uuid; + managedServerUuid uuid; +begin + currentTask := 'creating booking-item test-data ' || givenPartnerNumber::text || givenDebitorSuffix; + call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global:ADMIN'); + execute format('set local hsadminng.currentTask to %L', currentTask); + + select project.* into relatedProject + from hs_booking_project project + where project.caption = 'D-' || givenPartnerNumber || givenDebitorSuffix || ' default project'; + + raise notice 'creating test booking-item: %', givenPartnerNumber::text || givenDebitorSuffix::text; + raise notice '- using project (%): %', relatedProject.uuid, relatedProject; + privateCloudUuid := uuid_generate_v4(); + managedServerUuid := uuid_generate_v4(); + insert + into hs_booking_item (uuid, projectuuid, type, parentitemuuid, caption, validity, resources) + values (privateCloudUuid, relatedProject.uuid, 'PRIVATE_CLOUD', null, 'some PrivateCloud', daterange('20240401', null, '[]'), '{ "CPUs": 10, "RAM": 32, "SSD": 4000, "HDD": 10000, "Traffic": 2000 }'::jsonb), + (uuid_generate_v4(), null, 'MANAGED_SERVER', privateCloudUuid, 'some ManagedServer', daterange('20230115', '20240415', '[)'), '{ "CPUs": 2, "RAM": 4, "SSD": 500, "Traffic": 500 }'::jsonb), + (uuid_generate_v4(), null, 'CLOUD_SERVER', privateCloudUuid, 'test CloudServer', daterange('20230115', '20240415', '[)'), '{ "CPUs": 2, "RAM": 4, "SSD": 750, "Traffic": 500 }'::jsonb), + (uuid_generate_v4(), null, 'CLOUD_SERVER', privateCloudUuid, 'prod CloudServer', daterange('20230115', '20240415', '[)'), '{ "CPUs": 4, "RAM": 16, "SSD": 1000, "Traffic": 500 }'::jsonb), + (managedServerUuid, relatedProject.uuid, 'MANAGED_SERVER', null, 'separate ManagedServer', daterange('20221001', null, '[]'), '{ "CPUs": 2, "RAM": 8, "SSD": 500, "Traffic": 500 }'::jsonb), + (uuid_generate_v4(), null, 'MANAGED_WEBSPACE', managedServerUuid, 'some ManagedWebspace', daterange('20221001', null, '[]'), '{ "SSD": 50, "Traffic": 20, "Daemons": 2, "Multi": 4 }'::jsonb), + (uuid_generate_v4(), relatedProject.uuid, 'MANAGED_WEBSPACE', null, 'separate ManagedWebspace', daterange('20221001', null, '[]'), '{ "SSD": 100, "Traffic": 50, "Daemons": 0, "Multi": 1 }'::jsonb); +end; $$; +--// + + +-- ============================================================================ +--changeset hs-booking-item-TEST-DATA-GENERATION:1 –context=dev,tc endDelimiter:--// +-- ---------------------------------------------------------------------------- + +do language plpgsql $$ + begin + call createHsBookingItemTransactionTestData(10001, '11'); + call createHsBookingItemTransactionTestData(10002, '12'); + call createHsBookingItemTransactionTestData(10003, '13'); + end; +$$; +--// diff --git a/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6303-hs-booking-item-rbac.md b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6303-hs-booking-item-rbac.md new file mode 100644 index 00000000..4775616f --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6303-hs-booking-item-rbac.md @@ -0,0 +1,63 @@ +### rbac bookingItem + +This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. + +```mermaid +%%{init:{'flowchart':{'htmlLabels':false}}}%% +flowchart TB + +subgraph bookingItem["`**bookingItem**`"] + direction TB + style bookingItem fill:#dd4901,stroke:#274d6e,stroke-width:8px + + subgraph bookingItem:roles[ ] + style bookingItem:roles fill:#dd4901,stroke:white + + role:bookingItem:OWNER[[bookingItem:OWNER]] + role:bookingItem:ADMIN[[bookingItem:ADMIN]] + role:bookingItem:AGENT[[bookingItem:AGENT]] + role:bookingItem:TENANT[[bookingItem:TENANT]] + end + + subgraph bookingItem:permissions[ ] + style bookingItem:permissions fill:#dd4901,stroke:white + + perm:bookingItem:INSERT{{bookingItem:INSERT}} + perm:bookingItem:DELETE{{bookingItem:DELETE}} + perm:bookingItem:UPDATE{{bookingItem:UPDATE}} + perm:bookingItem:SELECT{{bookingItem:SELECT}} + end +end + +subgraph project["`**project**`"] + direction TB + style project fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph project:roles[ ] + style project:roles fill:#99bcdb,stroke:white + + role:project:OWNER[[project:OWNER]] + role:project:ADMIN[[project:ADMIN]] + role:project:AGENT[[project:AGENT]] + role:project:TENANT[[project:TENANT]] + end +end + +%% granting roles to roles +role:project:OWNER -.-> role:project:ADMIN +role:project:ADMIN -.-> role:project:AGENT +role:project:AGENT -.-> role:project:TENANT +role:project:AGENT ==> role:bookingItem:OWNER +role:bookingItem:OWNER ==> role:bookingItem:ADMIN +role:bookingItem:ADMIN ==> role:bookingItem:AGENT +role:bookingItem:AGENT ==> role:bookingItem:TENANT +role:bookingItem:TENANT ==> role:project:TENANT + +%% granting permissions to roles +role:global:ADMIN ==> perm:bookingItem:INSERT +role:global:ADMIN ==> perm:bookingItem:DELETE +role:project:ADMIN ==> perm:bookingItem:INSERT +role:bookingItem:ADMIN ==> perm:bookingItem:UPDATE +role:bookingItem:TENANT ==> perm:bookingItem:SELECT + +``` diff --git a/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6303-hs-booking-item-rbac.sql b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6303-hs-booking-item-rbac.sql new file mode 100644 index 00000000..bcd6523e --- /dev/null +++ b/src/main/resources/db/changelog/6-hs-booking/630-booking-item/6303-hs-booking-item-rbac.sql @@ -0,0 +1,277 @@ +--liquibase formatted sql +-- This code generated was by RbacViewPostgresGenerator, do not amend manually. + + +-- ============================================================================ +--changeset hs-booking-item-rbac-OBJECT:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRelatedRbacObject('hs_booking_item'); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRoleDescriptors('hsBookingItem', 'hs_booking_item'); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-insert-trigger:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/* + Creates the roles, grants and permission for the AFTER INSERT TRIGGER. + */ + +create or replace procedure buildRbacSystemForHsBookingItem( + NEW hs_booking_item +) + language plpgsql as $$ + +declare + newProject hs_booking_project; + newParentItem hs_booking_item; + +begin + call enterTriggerForObjectUuid(NEW.uuid); + + SELECT * FROM hs_booking_project WHERE uuid = NEW.projectUuid INTO newProject; + + SELECT * FROM hs_booking_item WHERE uuid = NEW.parentItemUuid INTO newParentItem; + + perform createRoleWithGrants( + hsBookingItemOWNER(NEW), + incomingSuperRoles => array[ + hsBookingItemAGENT(newParentItem), + hsBookingProjectAGENT(newProject)] + ); + + perform createRoleWithGrants( + hsBookingItemADMIN(NEW), + permissions => array['UPDATE'], + incomingSuperRoles => array[hsBookingItemOWNER(NEW)] + ); + + perform createRoleWithGrants( + hsBookingItemAGENT(NEW), + incomingSuperRoles => array[hsBookingItemADMIN(NEW)] + ); + + perform createRoleWithGrants( + hsBookingItemTENANT(NEW), + permissions => array['SELECT'], + incomingSuperRoles => array[hsBookingItemAGENT(NEW)], + outgoingSubRoles => array[ + hsBookingItemTENANT(newParentItem), + hsBookingProjectTENANT(newProject)] + ); + + + + call grantPermissionToRole(createPermission(NEW.uuid, 'DELETE'), globalAdmin()); + + call leaveTriggerForObjectUuid(NEW.uuid); +end; $$; + +/* + AFTER INSERT TRIGGER to create the role+grant structure for a new hs_booking_item row. + */ + +create or replace function insertTriggerForHsBookingItem_tf() + returns trigger + language plpgsql + strict as $$ +begin + call buildRbacSystemForHsBookingItem(NEW); + return NEW; +end; $$; + +create trigger insertTriggerForHsBookingItem_tg + after insert on hs_booking_item + for each row +execute procedure insertTriggerForHsBookingItem_tf(); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +-- granting INSERT permission to global ---------------------------- + +/* + Grants INSERT INTO hs_booking_item permissions to specified role of pre-existing global rows. + */ +do language plpgsql $$ + declare + row global; + begin + call defineContext('create INSERT INTO hs_booking_item permissions for pre-exising global rows'); + + FOR row IN SELECT * FROM global + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_booking_item'), + globalADMIN()); + END LOOP; + end; +$$; + +/** + Grants hs_booking_item INSERT permission to specified role of new global rows. +*/ +create or replace function new_hs_booking_item_grants_insert_to_global_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + globalADMIN()); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_item_grants_insert_to_global_tg + after insert on global + for each row +execute procedure new_hs_booking_item_grants_insert_to_global_tf(); + +-- granting INSERT permission to hs_booking_project ---------------------------- + +/* + Grants INSERT INTO hs_booking_item permissions to specified role of pre-existing hs_booking_project rows. + */ +do language plpgsql $$ + declare + row hs_booking_project; + begin + call defineContext('create INSERT INTO hs_booking_item permissions for pre-exising hs_booking_project rows'); + + FOR row IN SELECT * FROM hs_booking_project + -- unconditional for all rows in that table + LOOP + call grantPermissionToRole( + createPermission(row.uuid, 'INSERT', 'hs_booking_item'), + hsBookingProjectADMIN(row)); + END LOOP; + end; +$$; + +/** + Grants hs_booking_item INSERT permission to specified role of new hs_booking_project rows. +*/ +create or replace function new_hs_booking_item_grants_insert_to_hs_booking_project_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + hsBookingProjectADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_item_grants_insert_to_hs_booking_project_tg + after insert on hs_booking_project + for each row +execute procedure new_hs_booking_item_grants_insert_to_hs_booking_project_tf(); + +-- granting INSERT permission to hs_booking_item ---------------------------- + +-- Granting INSERT INTO hs_hosting_asset permissions to specified role of pre-existing hs_hosting_asset rows slipped, +-- because there cannot yet be any pre-existing rows in the same table yet. + +/** + Grants hs_booking_item INSERT permission to specified role of new hs_booking_item rows. +*/ +create or replace function new_hs_booking_item_grants_insert_to_hs_booking_item_tf() + returns trigger + language plpgsql + strict as $$ +begin + -- unconditional for all rows in that table + call grantPermissionToRole( + createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'), + hsBookingItemADMIN(NEW)); + -- end. + return NEW; +end; $$; + +-- z_... is to put it at the end of after insert triggers, to make sure the roles exist +create trigger z_new_hs_booking_item_grants_insert_to_hs_booking_item_tg + after insert on hs_booking_item + for each row +execute procedure new_hs_booking_item_grants_insert_to_hs_booking_item_tf(); + + +-- ============================================================================ +--changeset hs_booking_item-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +/** + Checks if the user respectively the assumed roles are allowed to insert a row to hs_booking_item. +*/ +create or replace function hs_booking_item_insert_permission_check_tf() + returns trigger + language plpgsql as $$ +declare + superObjectUuid uuid; +begin + -- check INSERT INSERT if global ADMIN + if isGlobalAdmin() then + return NEW; + end if; + -- check INSERT permission via direct foreign key: NEW.projectUuid + if hasInsertPermission(NEW.projectUuid, 'hs_booking_item') then + return NEW; + end if; + -- check INSERT permission via direct foreign key: NEW.parentItemUuid + if hasInsertPermission(NEW.parentItemUuid, 'hs_booking_item') then + return NEW; + end if; + + raise exception '[403] insert into hs_booking_item values(%) not allowed for current subjects % (%)', + NEW, currentSubjects(), currentSubjectsUuids(); +end; $$; + +create trigger hs_booking_item_insert_permission_check_tg + before insert on hs_booking_item + for each row + execute procedure hs_booking_item_insert_permission_check_tf(); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-IDENTITY-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- + +call generateRbacIdentityViewFromProjection('hs_booking_item', + $idName$ + caption + $idName$); +--// + + +-- ============================================================================ +--changeset hs-booking-item-rbac-RESTRICTED-VIEW:1 endDelimiter:--// +-- ---------------------------------------------------------------------------- +call generateRbacRestrictedView('hs_booking_item', + $orderBy$ + validity + $orderBy$, + $updates$ + version = new.version, + caption = new.caption, + validity = new.validity, + resources = new.resources + $updates$); +--// + diff --git a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7010-hs-hosting-asset.sql b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7010-hs-hosting-asset.sql index 4aa9e099..c1b4bbcc 100644 --- a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7010-hs-hosting-asset.sql +++ b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7010-hs-hosting-asset.sql @@ -10,6 +10,10 @@ create type HsHostingAssetType as enum ( 'MANAGED_WEBSPACE', 'UNIX_USER', 'DOMAIN_SETUP', + 'DOMAIN_DNS_SETUP', + 'DOMAIN_HTTP_SETUP', + 'DOMAIN_SMTP_SETUP', + 'DOMAIN_MBOX_SETUP', 'EMAIL_ALIAS', 'EMAIL_ADDRESS', 'PGSQL_USER', @@ -26,12 +30,15 @@ create table if not exists hs_hosting_asset version int not null default 0, bookingItemUuid uuid null references hs_booking_item(uuid), type HsHostingAssetType not null, - parentAssetUuid uuid null references hs_hosting_asset(uuid), + parentAssetUuid uuid null references hs_hosting_asset(uuid) initially deferred, + assignedToAssetUuid uuid null references hs_hosting_asset(uuid) initially deferred, identifier varchar(80) not null, - caption varchar(80) not null, + caption varchar(80), config jsonb not null, + alarmContactUuid uuid null references hs_office_contact(uuid) initially deferred, - constraint chk_hs_hosting_asset_has_booking_item_or_parent_asset check (bookingItemUuid is not null or parentAssetUuid is not null) + constraint chk_hs_hosting_asset_has_booking_item_or_parent_asset + check (bookingItemUuid is not null or parentAssetUuid is not null or type='DOMAIN_SETUP') ); --// @@ -58,9 +65,13 @@ begin when 'MANAGED_SERVER' then null when 'MANAGED_WEBSPACE' then 'MANAGED_SERVER' when 'UNIX_USER' then 'MANAGED_WEBSPACE' - when 'DOMAIN_SETUP' then 'UNIX_USER' when 'EMAIL_ALIAS' then 'MANAGED_WEBSPACE' - when 'EMAIL_ADDRESS' then 'DOMAIN_SETUP' + when 'DOMAIN_SETUP' then null + when 'DOMAIN_DNS_SETUP' then 'DOMAIN_SETUP' + when 'DOMAIN_HTTP_SETUP' then 'DOMAIN_SETUP' + when 'DOMAIN_SMTP_SETUP' then 'DOMAIN_SETUP' + when 'DOMAIN_MBOX_SETUP' then 'DOMAIN_SETUP' + when 'EMAIL_ADDRESS' then 'DOMAIN_MBOX_SETUP' when 'PGSQL_USER' then 'MANAGED_WEBSPACE' when 'PGSQL_DATABASE' then 'MANAGED_WEBSPACE' when 'MARIADB_USER' then 'MANAGED_WEBSPACE' @@ -69,10 +80,10 @@ begin end); if expectedParentType is not null and actualParentType is null then - raise exception '[400] % must have % as parent, but got ', + raise exception '[400] HostingAsset % must have % as parent, but got ', NEW.type, expectedParentType; elsif expectedParentType is not null and actualParentType <> expectedParentType then - raise exception '[400] % must have % as parent, but got %s', + raise exception '[400] HostingAsset % must have % as parent, but got %s', NEW.type, expectedParentType, actualParentType; end if; return NEW; @@ -94,27 +105,23 @@ create or replace function hs_hosting_asset_booking_item_hierarchy_check_tf() language plpgsql as $$ declare actualBookingItemType HsBookingItemType; - expectedBookingItemTypes HsBookingItemType[]; + expectedBookingItemType HsBookingItemType; begin actualBookingItemType := (select type from hs_booking_item where NEW.bookingItemUuid = uuid); if NEW.type = 'CLOUD_SERVER' then - expectedBookingItemTypes := ARRAY['PRIVATE_CLOUD', 'CLOUD_SERVER']; + expectedBookingItemType := 'CLOUD_SERVER'; elsif NEW.type = 'MANAGED_SERVER' then - expectedBookingItemTypes := ARRAY['PRIVATE_CLOUD', 'MANAGED_SERVER']; + expectedBookingItemType := 'MANAGED_SERVER'; elsif NEW.type = 'MANAGED_WEBSPACE' then - if NEW.parentAssetUuid is null then - expectedBookingItemTypes := ARRAY['MANAGED_WEBSPACE']; - else - expectedBookingItemTypes := ARRAY['PRIVATE_CLOUD', 'MANAGED_SERVER']; - end if; + expectedBookingItemType := 'MANAGED_WEBSPACE'; end if; - if not actualBookingItemType = any(expectedBookingItemTypes) then - raise exception '[400] % % must have any of % as booking-item, but got %', - NEW.type, NEW.identifier, expectedBookingItemTypes, actualBookingItemType; + if not actualBookingItemType = expectedBookingItemType then + raise exception '[400] HostingAsset % % must have % as booking-item, but got %', + NEW.type, NEW.identifier, expectedBookingItemType, actualBookingItemType; end if; return NEW; end; $$; diff --git a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac-CLOUD_SERVER.md b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac-CLOUD_SERVER.md deleted file mode 100644 index 65ae6608..00000000 --- a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac-CLOUD_SERVER.md +++ /dev/null @@ -1,92 +0,0 @@ -### rbac asset inCaseOf:CLOUD_SERVER - -This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. - -```mermaid -%%{init:{'flowchart':{'htmlLabels':false}}}%% -flowchart TB - -subgraph asset["`**asset**`"] - direction TB - style asset fill:#dd4901,stroke:#274d6e,stroke-width:8px - - subgraph asset:roles[ ] - style asset:roles fill:#dd4901,stroke:white - - role:asset:OWNER[[asset:OWNER]] - role:asset:ADMIN[[asset:ADMIN]] - role:asset:TENANT[[asset:TENANT]] - end - - subgraph asset:permissions[ ] - style asset:permissions fill:#dd4901,stroke:white - - perm:asset:INSERT{{asset:INSERT}} - perm:asset:DELETE{{asset:DELETE}} - perm:asset:UPDATE{{asset:UPDATE}} - perm:asset:SELECT{{asset:SELECT}} - end -end - -subgraph bookingItem["`**bookingItem**`"] - direction TB - style bookingItem fill:#99bcdb,stroke:#274d6e,stroke-width:8px - - subgraph bookingItem:roles[ ] - style bookingItem:roles fill:#99bcdb,stroke:white - - role:bookingItem:OWNER[[bookingItem:OWNER]] - role:bookingItem:ADMIN[[bookingItem:ADMIN]] - role:bookingItem:AGENT[[bookingItem:AGENT]] - role:bookingItem:TENANT[[bookingItem:TENANT]] - end -end - -subgraph bookingItem.debitorRel["`**bookingItem.debitorRel**`"] - direction TB - style bookingItem.debitorRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px - - subgraph bookingItem.debitorRel:roles[ ] - style bookingItem.debitorRel:roles fill:#99bcdb,stroke:white - - role:bookingItem.debitorRel:OWNER[[bookingItem.debitorRel:OWNER]] - role:bookingItem.debitorRel:ADMIN[[bookingItem.debitorRel:ADMIN]] - role:bookingItem.debitorRel:AGENT[[bookingItem.debitorRel:AGENT]] - role:bookingItem.debitorRel:TENANT[[bookingItem.debitorRel:TENANT]] - end -end - -subgraph parentServer["`**parentServer**`"] - direction TB - style parentServer fill:#99bcdb,stroke:#274d6e,stroke-width:8px - - subgraph parentServer:roles[ ] - style parentServer:roles fill:#99bcdb,stroke:white - - role:parentServer:ADMIN[[parentServer:ADMIN]] - end -end - -%% granting roles to roles -role:global:ADMIN -.-> role:bookingItem.debitorRel:OWNER -role:bookingItem.debitorRel:OWNER -.-> role:bookingItem.debitorRel:ADMIN -role:bookingItem.debitorRel:ADMIN -.-> role:bookingItem.debitorRel:AGENT -role:bookingItem.debitorRel:AGENT -.-> role:bookingItem.debitorRel:TENANT -role:bookingItem.debitorRel:AGENT -.-> role:bookingItem:OWNER -role:bookingItem:OWNER -.-> role:bookingItem:ADMIN -role:bookingItem.debitorRel:AGENT -.-> role:bookingItem:ADMIN -role:bookingItem:ADMIN -.-> role:bookingItem:AGENT -role:bookingItem:AGENT -.-> role:bookingItem:TENANT -role:bookingItem:TENANT -.-> role:bookingItem.debitorRel:TENANT -role:bookingItem:ADMIN ==> role:asset:OWNER -role:asset:OWNER ==> role:asset:ADMIN -role:asset:ADMIN ==> role:asset:TENANT -role:asset:TENANT ==> role:bookingItem:TENANT - -%% granting permissions to roles -role:bookingItem:AGENT ==> perm:asset:INSERT -role:asset:OWNER ==> perm:asset:DELETE -role:asset:ADMIN ==> perm:asset:UPDATE -role:asset:TENANT ==> perm:asset:SELECT - -``` diff --git a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac-MANAGED_SERVER.md b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac-MANAGED_SERVER.md deleted file mode 100644 index 773ae411..00000000 --- a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac-MANAGED_SERVER.md +++ /dev/null @@ -1,92 +0,0 @@ -### rbac asset inCaseOf:MANAGED_SERVER - -This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. - -```mermaid -%%{init:{'flowchart':{'htmlLabels':false}}}%% -flowchart TB - -subgraph asset["`**asset**`"] - direction TB - style asset fill:#dd4901,stroke:#274d6e,stroke-width:8px - - subgraph asset:roles[ ] - style asset:roles fill:#dd4901,stroke:white - - role:asset:OWNER[[asset:OWNER]] - role:asset:ADMIN[[asset:ADMIN]] - role:asset:TENANT[[asset:TENANT]] - end - - subgraph asset:permissions[ ] - style asset:permissions fill:#dd4901,stroke:white - - perm:asset:INSERT{{asset:INSERT}} - perm:asset:DELETE{{asset:DELETE}} - perm:asset:UPDATE{{asset:UPDATE}} - perm:asset:SELECT{{asset:SELECT}} - end -end - -subgraph bookingItem["`**bookingItem**`"] - direction TB - style bookingItem fill:#99bcdb,stroke:#274d6e,stroke-width:8px - - subgraph bookingItem:roles[ ] - style bookingItem:roles fill:#99bcdb,stroke:white - - role:bookingItem:OWNER[[bookingItem:OWNER]] - role:bookingItem:ADMIN[[bookingItem:ADMIN]] - role:bookingItem:AGENT[[bookingItem:AGENT]] - role:bookingItem:TENANT[[bookingItem:TENANT]] - end -end - -subgraph bookingItem.debitorRel["`**bookingItem.debitorRel**`"] - direction TB - style bookingItem.debitorRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px - - subgraph bookingItem.debitorRel:roles[ ] - style bookingItem.debitorRel:roles fill:#99bcdb,stroke:white - - role:bookingItem.debitorRel:OWNER[[bookingItem.debitorRel:OWNER]] - role:bookingItem.debitorRel:ADMIN[[bookingItem.debitorRel:ADMIN]] - role:bookingItem.debitorRel:AGENT[[bookingItem.debitorRel:AGENT]] - role:bookingItem.debitorRel:TENANT[[bookingItem.debitorRel:TENANT]] - end -end - -subgraph parentServer["`**parentServer**`"] - direction TB - style parentServer fill:#99bcdb,stroke:#274d6e,stroke-width:8px - - subgraph parentServer:roles[ ] - style parentServer:roles fill:#99bcdb,stroke:white - - role:parentServer:ADMIN[[parentServer:ADMIN]] - end -end - -%% granting roles to roles -role:global:ADMIN -.-> role:bookingItem.debitorRel:OWNER -role:bookingItem.debitorRel:OWNER -.-> role:bookingItem.debitorRel:ADMIN -role:bookingItem.debitorRel:ADMIN -.-> role:bookingItem.debitorRel:AGENT -role:bookingItem.debitorRel:AGENT -.-> role:bookingItem.debitorRel:TENANT -role:bookingItem.debitorRel:AGENT -.-> role:bookingItem:OWNER -role:bookingItem:OWNER -.-> role:bookingItem:ADMIN -role:bookingItem.debitorRel:AGENT -.-> role:bookingItem:ADMIN -role:bookingItem:ADMIN -.-> role:bookingItem:AGENT -role:bookingItem:AGENT -.-> role:bookingItem:TENANT -role:bookingItem:TENANT -.-> role:bookingItem.debitorRel:TENANT -role:bookingItem:ADMIN ==> role:asset:OWNER -role:asset:OWNER ==> role:asset:ADMIN -role:asset:ADMIN ==> role:asset:TENANT -role:asset:TENANT ==> role:bookingItem:TENANT - -%% granting permissions to roles -role:bookingItem:AGENT ==> perm:asset:INSERT -role:asset:OWNER ==> perm:asset:DELETE -role:asset:ADMIN ==> perm:asset:UPDATE -role:asset:TENANT ==> perm:asset:SELECT - -``` diff --git a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac-MANAGED_WEBSPACE.md b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac-MANAGED_WEBSPACE.md deleted file mode 100644 index e9b929a9..00000000 --- a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac-MANAGED_WEBSPACE.md +++ /dev/null @@ -1,93 +0,0 @@ -### rbac asset inCaseOf:MANAGED_WEBSPACE - -This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. - -```mermaid -%%{init:{'flowchart':{'htmlLabels':false}}}%% -flowchart TB - -subgraph asset["`**asset**`"] - direction TB - style asset fill:#dd4901,stroke:#274d6e,stroke-width:8px - - subgraph asset:roles[ ] - style asset:roles fill:#dd4901,stroke:white - - role:asset:OWNER[[asset:OWNER]] - role:asset:ADMIN[[asset:ADMIN]] - role:asset:TENANT[[asset:TENANT]] - end - - subgraph asset:permissions[ ] - style asset:permissions fill:#dd4901,stroke:white - - perm:asset:INSERT{{asset:INSERT}} - perm:asset:DELETE{{asset:DELETE}} - perm:asset:UPDATE{{asset:UPDATE}} - perm:asset:SELECT{{asset:SELECT}} - end -end - -subgraph bookingItem["`**bookingItem**`"] - direction TB - style bookingItem fill:#99bcdb,stroke:#274d6e,stroke-width:8px - - subgraph bookingItem:roles[ ] - style bookingItem:roles fill:#99bcdb,stroke:white - - role:bookingItem:OWNER[[bookingItem:OWNER]] - role:bookingItem:ADMIN[[bookingItem:ADMIN]] - role:bookingItem:AGENT[[bookingItem:AGENT]] - role:bookingItem:TENANT[[bookingItem:TENANT]] - end -end - -subgraph bookingItem.debitorRel["`**bookingItem.debitorRel**`"] - direction TB - style bookingItem.debitorRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px - - subgraph bookingItem.debitorRel:roles[ ] - style bookingItem.debitorRel:roles fill:#99bcdb,stroke:white - - role:bookingItem.debitorRel:OWNER[[bookingItem.debitorRel:OWNER]] - role:bookingItem.debitorRel:ADMIN[[bookingItem.debitorRel:ADMIN]] - role:bookingItem.debitorRel:AGENT[[bookingItem.debitorRel:AGENT]] - role:bookingItem.debitorRel:TENANT[[bookingItem.debitorRel:TENANT]] - end -end - -subgraph parentServer["`**parentServer**`"] - direction TB - style parentServer fill:#99bcdb,stroke:#274d6e,stroke-width:8px - - subgraph parentServer:roles[ ] - style parentServer:roles fill:#99bcdb,stroke:white - - role:parentServer:ADMIN[[parentServer:ADMIN]] - end -end - -%% granting roles to roles -role:global:ADMIN -.-> role:bookingItem.debitorRel:OWNER -role:bookingItem.debitorRel:OWNER -.-> role:bookingItem.debitorRel:ADMIN -role:bookingItem.debitorRel:ADMIN -.-> role:bookingItem.debitorRel:AGENT -role:bookingItem.debitorRel:AGENT -.-> role:bookingItem.debitorRel:TENANT -role:bookingItem.debitorRel:AGENT -.-> role:bookingItem:OWNER -role:bookingItem:OWNER -.-> role:bookingItem:ADMIN -role:bookingItem.debitorRel:AGENT -.-> role:bookingItem:ADMIN -role:bookingItem:ADMIN -.-> role:bookingItem:AGENT -role:bookingItem:AGENT -.-> role:bookingItem:TENANT -role:bookingItem:TENANT -.-> role:bookingItem.debitorRel:TENANT -role:bookingItem:ADMIN ==> role:asset:OWNER -role:asset:OWNER ==> role:asset:ADMIN -role:asset:ADMIN ==> role:asset:TENANT -role:asset:TENANT ==> role:bookingItem:TENANT - -%% granting permissions to roles -role:bookingItem:AGENT ==> perm:asset:INSERT -role:parentServer:ADMIN ==> perm:asset:INSERT -role:asset:OWNER ==> perm:asset:DELETE -role:asset:ADMIN ==> perm:asset:UPDATE -role:asset:TENANT ==> perm:asset:SELECT - -``` diff --git a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.md b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.md index cbbd80c0..019bb0a2 100644 --- a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.md +++ b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.md @@ -1,4 +1,4 @@ -### rbac asset inOtherCases +### rbac asset This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually. @@ -6,6 +6,19 @@ This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manua %%{init:{'flowchart':{'htmlLabels':false}}}%% flowchart TB +subgraph alarmContact["`**alarmContact**`"] + direction TB + style alarmContact fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph alarmContact:roles[ ] + style alarmContact:roles fill:#99bcdb,stroke:white + + role:alarmContact:OWNER[[alarmContact:OWNER]] + role:alarmContact:ADMIN[[alarmContact:ADMIN]] + role:alarmContact:REFERRER[[alarmContact:REFERRER]] + end +end + subgraph asset["`**asset**`"] direction TB style asset fill:#dd4901,stroke:#274d6e,stroke-width:8px @@ -15,6 +28,7 @@ subgraph asset["`**asset**`"] role:asset:OWNER[[asset:OWNER]] role:asset:ADMIN[[asset:ADMIN]] + role:asset:AGENT[[asset:AGENT]] role:asset:TENANT[[asset:TENANT]] end @@ -28,6 +42,17 @@ subgraph asset["`**asset**`"] end end +subgraph assignedToAsset["`**assignedToAsset**`"] + direction TB + style assignedToAsset fill:#99bcdb,stroke:#274d6e,stroke-width:8px + + subgraph assignedToAsset:roles[ ] + style assignedToAsset:roles fill:#99bcdb,stroke:white + + role:assignedToAsset:TENANT[[assignedToAsset:TENANT]] + end +end + subgraph bookingItem["`**bookingItem**`"] direction TB style bookingItem fill:#99bcdb,stroke:#274d6e,stroke-width:8px @@ -42,48 +67,47 @@ subgraph bookingItem["`**bookingItem**`"] end end -subgraph bookingItem.debitorRel["`**bookingItem.debitorRel**`"] +subgraph parentAsset["`**parentAsset**`"] direction TB - style bookingItem.debitorRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px + style parentAsset fill:#99bcdb,stroke:#274d6e,stroke-width:8px - subgraph bookingItem.debitorRel:roles[ ] - style bookingItem.debitorRel:roles fill:#99bcdb,stroke:white + subgraph parentAsset:roles[ ] + style parentAsset:roles fill:#99bcdb,stroke:white - role:bookingItem.debitorRel:OWNER[[bookingItem.debitorRel:OWNER]] - role:bookingItem.debitorRel:ADMIN[[bookingItem.debitorRel:ADMIN]] - role:bookingItem.debitorRel:AGENT[[bookingItem.debitorRel:AGENT]] - role:bookingItem.debitorRel:TENANT[[bookingItem.debitorRel:TENANT]] + role:parentAsset:ADMIN[[parentAsset:ADMIN]] + role:parentAsset:AGENT[[parentAsset:AGENT]] + role:parentAsset:TENANT[[parentAsset:TENANT]] end end -subgraph parentServer["`**parentServer**`"] - direction TB - style parentServer fill:#99bcdb,stroke:#274d6e,stroke-width:8px - - subgraph parentServer:roles[ ] - style parentServer:roles fill:#99bcdb,stroke:white - - role:parentServer:ADMIN[[parentServer:ADMIN]] - end -end +%% granting roles to users +user:creator ==> role:asset:OWNER %% granting roles to roles -role:global:ADMIN -.-> role:bookingItem.debitorRel:OWNER -role:bookingItem.debitorRel:OWNER -.-> role:bookingItem.debitorRel:ADMIN -role:bookingItem.debitorRel:ADMIN -.-> role:bookingItem.debitorRel:AGENT -role:bookingItem.debitorRel:AGENT -.-> role:bookingItem.debitorRel:TENANT -role:bookingItem.debitorRel:AGENT -.-> role:bookingItem:OWNER role:bookingItem:OWNER -.-> role:bookingItem:ADMIN -role:bookingItem.debitorRel:AGENT -.-> role:bookingItem:ADMIN role:bookingItem:ADMIN -.-> role:bookingItem:AGENT role:bookingItem:AGENT -.-> role:bookingItem:TENANT -role:bookingItem:TENANT -.-> role:bookingItem.debitorRel:TENANT +role:global:ADMIN -.-> role:alarmContact:OWNER +role:alarmContact:OWNER -.-> role:alarmContact:ADMIN +role:alarmContact:ADMIN -.-> role:alarmContact:REFERRER +role:global:ADMIN ==>|XX| role:asset:OWNER role:bookingItem:ADMIN ==> role:asset:OWNER +role:parentAsset:ADMIN ==> role:asset:OWNER role:asset:OWNER ==> role:asset:ADMIN -role:asset:ADMIN ==> role:asset:TENANT +role:bookingItem:AGENT ==> role:asset:ADMIN +role:parentAsset:AGENT ==> role:asset:ADMIN +role:asset:ADMIN ==> role:asset:AGENT +role:asset:AGENT ==> role:assignedToAsset:TENANT +role:asset:AGENT ==> role:alarmContact:REFERRER +role:asset:AGENT ==> role:asset:TENANT role:asset:TENANT ==> role:bookingItem:TENANT +role:asset:TENANT ==> role:parentAsset:TENANT +role:alarmContact:ADMIN ==> role:asset:TENANT %% granting permissions to roles +role:global:ADMIN ==> perm:asset:INSERT +role:parentAsset:ADMIN ==> perm:asset:INSERT +role:global:GUEST ==> perm:asset:INSERT role:asset:OWNER ==> perm:asset:DELETE role:asset:ADMIN ==> perm:asset:UPDATE role:asset:TENANT ==> perm:asset:SELECT diff --git a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.sql b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.sql index 2495f1ea..91afe2b6 100644 --- a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.sql +++ b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7013-hs-hosting-asset-rbac.sql @@ -30,39 +30,61 @@ create or replace procedure buildRbacSystemForHsHostingAsset( language plpgsql as $$ declare - newParentServer hs_hosting_asset; newBookingItem hs_booking_item; + newAssignedToAsset hs_hosting_asset; + newAlarmContact hs_office_contact; + newParentAsset hs_hosting_asset; begin call enterTriggerForObjectUuid(NEW.uuid); - SELECT * FROM hs_hosting_asset WHERE uuid = NEW.parentAssetUuid INTO newParentServer; - SELECT * FROM hs_booking_item WHERE uuid = NEW.bookingItemUuid INTO newBookingItem; + SELECT * FROM hs_hosting_asset WHERE uuid = NEW.assignedToAssetUuid INTO newAssignedToAsset; + + SELECT * FROM hs_office_contact WHERE uuid = NEW.alarmContactUuid INTO newAlarmContact; + + SELECT * FROM hs_hosting_asset WHERE uuid = NEW.parentAssetUuid INTO newParentAsset; + perform createRoleWithGrants( hsHostingAssetOWNER(NEW), permissions => array['DELETE'], - incomingSuperRoles => array[hsBookingItemADMIN(newBookingItem)] + incomingSuperRoles => array[ + globalADMIN(unassumed()), + hsBookingItemADMIN(newBookingItem), + hsHostingAssetADMIN(newParentAsset)], + userUuids => array[currentUserUuid()] ); perform createRoleWithGrants( hsHostingAssetADMIN(NEW), permissions => array['UPDATE'], - incomingSuperRoles => array[hsHostingAssetOWNER(NEW)] + incomingSuperRoles => array[ + hsBookingItemAGENT(newBookingItem), + hsHostingAssetAGENT(newParentAsset), + hsHostingAssetOWNER(NEW)] + ); + + perform createRoleWithGrants( + hsHostingAssetAGENT(NEW), + incomingSuperRoles => array[hsHostingAssetADMIN(NEW)], + outgoingSubRoles => array[ + hsHostingAssetTENANT(newAssignedToAsset), + hsOfficeContactREFERRER(newAlarmContact)] ); perform createRoleWithGrants( hsHostingAssetTENANT(NEW), permissions => array['SELECT'], - incomingSuperRoles => array[hsHostingAssetADMIN(NEW)], - outgoingSubRoles => array[hsBookingItemTENANT(newBookingItem)] + incomingSuperRoles => array[ + hsHostingAssetAGENT(NEW), + hsOfficeContactADMIN(newAlarmContact)], + outgoingSubRoles => array[ + hsBookingItemTENANT(newBookingItem), + hsHostingAssetTENANT(newParentAsset)] ); - IF NEW.type = 'CLOUD_SERVER' THEN - ELSIF NEW.type = 'MANAGED_SERVER' THEN - ELSIF NEW.type = 'MANAGED_WEBSPACE' THEN - ELSE + IF NEW.type = 'DOMAIN_SETUP' THEN END IF; call leaveTriggerForObjectUuid(NEW.uuid); @@ -89,110 +111,44 @@ execute procedure insertTriggerForHsHostingAsset_tf(); -- ============================================================================ ---changeset hs-hosting-asset-rbac-GRANTING-INSERT-PERMISSION:1 endDelimiter:--// +--changeset hs-hosting-asset-rbac-update-trigger:1 endDelimiter:--// -- ---------------------------------------------------------------------------- --- granting INSERT permission to hs_booking_item ---------------------------- - /* - Grants INSERT INTO hs_hosting_asset permissions to specified role of pre-existing hs_booking_item rows. + Called from the AFTER UPDATE TRIGGER to re-wire the grants. */ -do language plpgsql $$ - declare - row hs_booking_item; - begin - call defineContext('create INSERT INTO hs_hosting_asset permissions for pre-exising hs_booking_item rows'); - FOR row IN SELECT * FROM hs_booking_item - -- unconditional for all rows in that table - LOOP - call grantPermissionToRole( - createPermission(row.uuid, 'INSERT', 'hs_hosting_asset'), - hsBookingItemAGENT(row)); - END LOOP; - end; -$$; - -/** - Grants hs_hosting_asset INSERT permission to specified role of new hs_booking_item rows. -*/ -create or replace function new_hs_hosting_asset_grants_insert_to_hs_booking_item_tf() - returns trigger - language plpgsql - strict as $$ -begin - -- unconditional for all rows in that table - call grantPermissionToRole( - createPermission(NEW.uuid, 'INSERT', 'hs_hosting_asset'), - hsBookingItemAGENT(NEW)); - -- end. - return NEW; -end; $$; - --- z_... is to put it at the end of after insert triggers, to make sure the roles exist -create trigger z_new_hs_hosting_asset_grants_insert_to_hs_booking_item_tg - after insert on hs_booking_item - for each row -execute procedure new_hs_hosting_asset_grants_insert_to_hs_booking_item_tf(); - --- granting INSERT permission to hs_hosting_asset ---------------------------- - --- Granting INSERT INTO hs_hosting_asset permissions to specified role of pre-existing hs_hosting_asset rows slipped, --- because there cannot yet be any pre-existing rows in the same table yet. - -/** - Grants hs_hosting_asset INSERT permission to specified role of new hs_hosting_asset rows. -*/ -create or replace function new_hs_hosting_asset_grants_insert_to_hs_hosting_asset_tf() - returns trigger - language plpgsql - strict as $$ -begin - if NEW.type = 'MANAGED_SERVER' then - call grantPermissionToRole( - createPermission(NEW.uuid, 'INSERT', 'hs_hosting_asset'), - hsHostingAssetADMIN(NEW)); - end if; - return NEW; -end; $$; - --- z_... is to put it at the end of after insert triggers, to make sure the roles exist -create trigger z_new_hs_hosting_asset_grants_insert_to_hs_hosting_asset_tg - after insert on hs_hosting_asset - for each row -execute procedure new_hs_hosting_asset_grants_insert_to_hs_hosting_asset_tf(); - - --- ============================================================================ ---changeset hs_hosting_asset-rbac-CHECKING-INSERT-PERMISSION:1 endDelimiter:--// --- ---------------------------------------------------------------------------- - -/** - Checks if the user respectively the assumed roles are allowed to insert a row to hs_hosting_asset. -*/ -create or replace function hs_hosting_asset_insert_permission_check_tf() - returns trigger +create or replace procedure updateRbacRulesForHsHostingAsset( + OLD hs_hosting_asset, + NEW hs_hosting_asset +) language plpgsql as $$ -declare - superObjectUuid uuid; begin - -- check INSERT permission via direct foreign key: NEW.bookingItemUuid - if NEW.type in ('MANAGED_SERVER', 'CLOUD_SERVER', 'MANAGED_WEBSPACE') and hasInsertPermission(NEW.bookingItemUuid, 'hs_hosting_asset') then - return NEW; - end if; - -- check INSERT permission via direct foreign key: NEW.parentAssetUuid - if NEW.type in ('MANAGED_WEBSPACE') and hasInsertPermission(NEW.parentAssetUuid, 'hs_hosting_asset') then - return NEW; - end if; - raise exception '[403] insert into hs_hosting_asset not allowed for current subjects % (%)', - currentSubjects(), currentSubjectsUuids(); + if NEW.assignedToAssetUuid is distinct from OLD.assignedToAssetUuid + or NEW.alarmContactUuid is distinct from OLD.alarmContactUuid then + delete from rbacgrants g where g.grantedbytriggerof = OLD.uuid; + call buildRbacSystemForHsHostingAsset(NEW); + end if; end; $$; -create trigger hs_hosting_asset_insert_permission_check_tg - before insert on hs_hosting_asset +/* + AFTER INSERT TRIGGER to re-wire the grant structure for a new hs_hosting_asset row. + */ + +create or replace function updateTriggerForHsHostingAsset_tf() + returns trigger + language plpgsql + strict as $$ +begin + call updateRbacRulesForHsHostingAsset(OLD, NEW); + return NEW; +end; $$; + +create trigger updateTriggerForHsHostingAsset_tg + after update on hs_hosting_asset for each row - execute procedure hs_hosting_asset_insert_permission_check_tf(); +execute procedure updateTriggerForHsHostingAsset_tf(); --// @@ -200,11 +156,9 @@ create trigger hs_hosting_asset_insert_permission_check_tg --changeset hs-hosting-asset-rbac-IDENTITY-VIEW:1 endDelimiter:--// -- ---------------------------------------------------------------------------- -call generateRbacIdentityViewFromQuery('hs_hosting_asset', +call generateRbacIdentityViewFromProjection('hs_hosting_asset', $idName$ - SELECT asset.uuid as uuid, bookingItemIV.idName || '-' || cleanIdentifier(asset.identifier) as idName - FROM hs_hosting_asset asset - JOIN hs_booking_item_iv bookingItemIV ON bookingItemIV.uuid = asset.bookingItemUuid + identifier $idName$); --// @@ -219,7 +173,9 @@ call generateRbacRestrictedView('hs_hosting_asset', $updates$ version = new.version, caption = new.caption, - config = new.config + config = new.config, + assignedToAssetUuid = new.assignedToAssetUuid, + alarmContactUuid = new.alarmContactUuid $updates$); --// diff --git a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7018-hs-hosting-asset-test-data.sql b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7018-hs-hosting-asset-test-data.sql index e8bcbc05..f43edef0 100644 --- a/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7018-hs-hosting-asset-test-data.sql +++ b/src/main/resources/db/changelog/7-hs-hosting/701-hosting-asset/7018-hs-hosting-asset-test-data.sql @@ -8,46 +8,83 @@ /* Creates a single hs_hosting_asset test record. */ -create or replace procedure createHsHostingAssetTestData( - givenPartnerNumber numeric, - givenDebitorSuffix char(2), - givenWebspacePrefix char(3) - ) +create or replace procedure createHsHostingAssetTestData(givenProjectCaption varchar) language plpgsql as $$ declare - currentTask varchar; - relatedDebitor hs_office_debitor; - relatedPrivateCloudBookingItem hs_booking_item; - relatedManagedServerBookingItem hs_booking_item; - managedServerUuid uuid; + currentTask varchar; + relatedProject hs_booking_project; + relatedDebitor hs_office_debitor; + privateCloudBI hs_booking_item; + managedServerBI hs_booking_item; + cloudServerBI hs_booking_item; + managedWebspaceBI hs_booking_item; + debitorNumberSuffix varchar; + defaultPrefix varchar; + managedServerUuid uuid; + managedWebspaceUuid uuid; + webUnixUserUuid uuid; + domainSetupUuid uuid; + domainMBoxSetupUuid uuid; begin - currentTask := 'creating hosting-asset test-data ' || givenPartnerNumber::text || givenDebitorSuffix; + currentTask := 'creating hosting-asset test-data ' || givenProjectCaption; call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global:ADMIN'); execute format('set local hsadminng.currentTask to %L', currentTask); - select debitor.* into relatedDebitor - from hs_office_debitor debitor - join hs_office_relation debitorRel on debitorRel.uuid = debitor.debitorRelUuid - join hs_office_relation partnerRel on partnerRel.holderUuid = debitorRel.anchorUuid - join hs_office_partner partner on partner.partnerRelUuid = partnerRel.uuid - where partner.partnerNumber = givenPartnerNumber and debitor.debitorNumberSuffix = givenDebitorSuffix; - select item.uuid into relatedPrivateCloudBookingItem - from hs_booking_item item - where item.debitoruuid = relatedDebitor.uuid - and item.type = 'PRIVATE_CLOUD'; - select item.uuid into relatedManagedServerBookingItem - from hs_booking_item item - where item.debitoruuid = relatedDebitor.uuid - and item.type = 'MANAGED_SERVER'; - select uuid_generate_v4() into managedServerUuid; + select project.* into relatedProject + from hs_booking_project project + where project.caption = givenProjectCaption; + assert relatedProject.uuid is not null, 'relatedProject for "' || givenProjectCaption || '" must not be null'; + + select debitor.* into relatedDebitor + from hs_office_debitor debitor + where debitor.uuid = relatedProject.debitorUuid; + assert relatedDebitor.uuid is not null, 'relatedDebitor for "' || givenProjectCaption || '" must not be null'; + + select item.* into privateCloudBI + from hs_booking_item item + where item.projectUuid = relatedProject.uuid + and item.type = 'PRIVATE_CLOUD'; + assert privateCloudBI.uuid is not null, 'relatedPrivateCloudBookingItem for "' || givenProjectCaption|| '" must not be null'; + + select item.* into managedServerBI + from hs_booking_item item + where item.projectUuid = relatedProject.uuid + and item.type = 'MANAGED_SERVER'; + assert managedServerBI.uuid is not null, 'relatedManagedServerBookingItem for "' || givenProjectCaption|| '" must not be null'; + + select item.* into cloudServerBI + from hs_booking_item item + where item.parentItemuuid = privateCloudBI.uuid + and item.type = 'CLOUD_SERVER'; + assert cloudServerBI.uuid is not null, 'relatedCloudServerBookingItem for "' || givenProjectCaption|| '" must not be null'; + + select item.* into managedWebspaceBI + from hs_booking_item item + where item.projectUuid = relatedProject.uuid + and item.type = 'MANAGED_WEBSPACE'; + assert managedWebspaceBI.uuid is not null, 'relatedManagedWebspaceBookingItem for "' || givenProjectCaption|| '" must not be null'; + + select uuid_generate_v4() into managedServerUuid; + select uuid_generate_v4() into managedWebspaceUuid; + select uuid_generate_v4() into webUnixUserUuid; + select uuid_generate_v4() into domainSetupUuid; + select uuid_generate_v4() into domainMBoxSetupUuid; + debitorNumberSuffix := relatedDebitor.debitorNumberSuffix; + defaultPrefix := relatedDebitor.defaultPrefix; - raise notice 'creating test hosting-asset: %', givenPartnerNumber::text || givenDebitorSuffix::text; - raise notice '- using debitor (%): %', relatedDebitor.uuid, relatedDebitor; insert into hs_hosting_asset - (uuid, bookingitemuuid, type, parentAssetUuid, identifier, caption, config) - values (managedServerUuid, relatedPrivateCloudBookingItem.uuid, 'MANAGED_SERVER', null, 'vm10' || givenDebitorSuffix, 'some ManagedServer', '{ "CPU": 2, "SDD": 512, "extra": 42 }'::jsonb), - (uuid_generate_v4(), relatedPrivateCloudBookingItem.uuid, 'CLOUD_SERVER', null, 'vm20' || givenDebitorSuffix, 'another CloudServer', '{ "CPU": 2, "HDD": 1024, "extra": 42 }'::jsonb), - (uuid_generate_v4(), relatedManagedServerBookingItem.uuid, 'MANAGED_WEBSPACE', managedServerUuid, givenWebspacePrefix || '01', 'some Webspace', '{ "RAM": 1, "SDD": 512, "HDD": 2048, "extra": 42 }'::jsonb); + (uuid, bookingitemuuid, type, parentAssetUuid, assignedToAssetUuid, identifier, caption, config) + values (managedServerUuid, managedServerBI.uuid, 'MANAGED_SERVER', null, null, 'vm10' || debitorNumberSuffix, 'some ManagedServer', '{ "monit_max_cpu_usage": 90, "monit_max_ram_usage": 80, "monit_max_ssd_usage": 70 }'::jsonb), + (uuid_generate_v4(), cloudServerBI.uuid, 'CLOUD_SERVER', null, null, 'vm20' || debitorNumberSuffix, 'another CloudServer', '{}'::jsonb), + (managedWebspaceUuid, managedWebspaceBI.uuid, 'MANAGED_WEBSPACE', managedServerUuid, null, defaultPrefix || '01', 'some Webspace', '{}'::jsonb), + (uuid_generate_v4(), null, 'EMAIL_ALIAS', managedWebspaceUuid, null, defaultPrefix || '01-web', 'some E-Mail-Alias', '{ "target": [ "office@example.org", "archive@example.com" ] }'::jsonb), + (webUnixUserUuid, null, 'UNIX_USER', managedWebspaceUuid, null, defaultPrefix || '01-web', 'some UnixUser for Website', '{ "SSD-soft-quota": "128", "SSD-hard-quota": "256", "HDD-soft-quota": "512", "HDD-hard-quota": "1024"}'::jsonb), + (domainSetupUuid, null, 'DOMAIN_SETUP', null, null, defaultPrefix || '.example.org', 'some Domain-Setup', '{}'::jsonb), + (uuid_generate_v4(), null, 'DOMAIN_DNS_SETUP', domainSetupUuid, null, defaultPrefix || '.example.org|DNS', 'some Domain-DNS-Setup', '{}'::jsonb), + (uuid_generate_v4(), null, 'DOMAIN_HTTP_SETUP', domainSetupUuid, webUnixUserUuid, defaultPrefix || '.example.org|HTTP', 'some Domain-HTTP-Setup', '{ "option-htdocsfallback": true, "use-fcgiphpbin": "/usr/lib/cgi-bin/php", "validsubdomainnames": "*"}'::jsonb), + (uuid_generate_v4(), null, 'DOMAIN_SMTP_SETUP', domainSetupUuid, managedWebspaceUuid, defaultPrefix || '.example.org|DNS', 'some Domain-SMPT-Setup', '{}'::jsonb), + (domainMBoxSetupUuid, null, 'DOMAIN_MBOX_SETUP', domainSetupUuid, managedWebspaceUuid, defaultPrefix || '.example.org|DNS', 'some Domain-MBOX-Setup', '{}'::jsonb), + (uuid_generate_v4(), null, 'EMAIL_ADDRESS', domainMBoxSetupUuid, null, 'test@' || defaultPrefix || '.example.org', 'some E-Mail-Address', '{}'::jsonb); end; $$; --// @@ -58,9 +95,9 @@ end; $$; do language plpgsql $$ begin - call createHsHostingAssetTestData(10001, '11', 'aaa'); - call createHsHostingAssetTestData(10002, '12', 'bbb'); - call createHsHostingAssetTestData(10003, '13', 'ccc'); + call createHsHostingAssetTestData('D-1000111 default project'); + call createHsHostingAssetTestData('D-1000212 default project'); + call createHsHostingAssetTestData('D-1000313 default project'); end; $$; --// diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 90cbdcc2..d6b4942b 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -130,11 +130,19 @@ databaseChangeLog: - include: file: db/changelog/5-hs-office/512-coopassets/5128-hs-office-coopassets-test-data.sql - include: - file: db/changelog/6-hs-booking/601-booking-item/6010-hs-booking-item.sql + file: db/changelog/6-hs-booking/610-booking-debitor/6100-hs-booking-debitor.sql - include: - file: db/changelog/6-hs-booking/601-booking-item/6013-hs-booking-item-rbac.sql + file: db/changelog/6-hs-booking/620-booking-project/6200-hs-booking-project.sql - include: - file: db/changelog/6-hs-booking/601-booking-item/6018-hs-booking-item-test-data.sql + file: db/changelog/6-hs-booking/620-booking-project/6203-hs-booking-project-rbac.sql + - include: + file: db/changelog/6-hs-booking/620-booking-project/6208-hs-booking-project-test-data.sql + - include: + file: db/changelog/6-hs-booking/630-booking-item/6200-hs-booking-item.sql + - include: + file: db/changelog/6-hs-booking/630-booking-item/6203-hs-booking-item-rbac.sql + - include: + file: db/changelog/6-hs-booking/630-booking-item/6208-hs-booking-item-test-data.sql - include: file: db/changelog/7-hs-hosting/701-hosting-asset/7010-hs-hosting-asset.sql - include: diff --git a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java index 0cb1a086..cc2dafa6 100644 --- a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java +++ b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java @@ -8,7 +8,10 @@ import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.ConditionEvents; import com.tngtech.archunit.lang.SimpleConditionEvent; import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; +import net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantsDiagramService; import net.hostsharing.hsadminng.rbac.rbacobject.RbacObject; import org.springframework.data.repository.Repository; import org.springframework.web.bind.annotation.RestController; @@ -37,8 +40,11 @@ public class ArchitectureTest { "..test.pac", "..test.dom", "..context", + "..hash", "..generated..", "..persistence..", + "..system..", + "..validation..", "..hs.office.bankaccount", "..hs.office.contact", "..hs.office.coopassets", @@ -50,9 +56,12 @@ public class ArchitectureTest { "..hs.office.person", "..hs.office.relation", "..hs.office.sepamandate", + "..hs.booking.debitor", + "..hs.booking.project", "..hs.booking.item", + "..hs.booking.item.validators", "..hs.hosting.asset", - "..hs.hosting.asset.validator", + "..hs.hosting.asset.validators", "..errors", "..mapper", "..ping", @@ -103,6 +112,13 @@ public class ArchitectureTest { .should().onlyDependOnClassesThat() .resideOutsideOfPackage(NET_HOSTSHARING_HSADMINNG); + @ArchTest + @SuppressWarnings("unused") + public static final ArchRule hashPackageRule = classes() + .that().resideInAPackage("..hash..") + .should().onlyDependOnClassesThat() + .resideOutsideOfPackage(NET_HOSTSHARING_HSADMINNG); + @ArchTest @SuppressWarnings("unused") public static final ArchRule errorsPackageRule = classes() @@ -110,6 +126,13 @@ public class ArchitectureTest { .should().onlyDependOnClassesThat() .resideOutsideOfPackage(NET_HOSTSHARING_HSADMINNG); + @ArchTest + @SuppressWarnings("unused") + public static final ArchRule systemPackageRule = classes() + .that().resideInAPackage("..system..") + .should().onlyDependOnClassesThat() + .resideOutsideOfPackage(NET_HOSTSHARING_HSADMINNG); + @ArchTest @SuppressWarnings("unused") public static final ArchRule testPackagesRule = classes() @@ -143,7 +166,8 @@ public class ArchitectureTest { .should().onlyBeAccessed().byClassesThat() .resideInAnyPackage( "..hs.booking.(*)..", - "..hs.hosting.(*).." + "..hs.hosting.(*)..", + "..hs.validation" // TODO.impl: Some Validators need to be refactored to booking package. ); @ArchTest @@ -152,7 +176,8 @@ public class ArchitectureTest { .that().resideInAPackage("..hs.hosting.(*)..") .should().onlyBeAccessed().byClassesThat() .resideInAnyPackage( - "..hs.hosting.(*).." + "..hs.hosting.(*)..", + "..hs.booking.(*).." // TODO.impl: fix this cyclic dependency ); @ArchTest @@ -187,7 +212,9 @@ public class ArchitectureTest { "..hs.office.partner..", "..hs.office.debitor..", "..hs.office.membership..", - "..hs.office.migration.."); + "..hs.office.migration..", + "..hs.hosting.asset.." + ); @ArchTest @SuppressWarnings("unused") @@ -292,9 +319,13 @@ public class ArchitectureTest { static final ArchRule everythingShouldBeFreeOfCycles = slices().matching("net.hostsharing.hsadminng.(*)..") .should().beFreeOfCycles() + // TODO.refa: would be great if we could get rid of these cyclic dependencies .ignoreDependency( ContextBasedTest.class, - net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantsDiagramService.class); + RbacGrantsDiagramService.class) + .ignoreDependency( + HsBookingItemEntity.class, + HsHostingAssetEntity.class); @ArchTest diff --git a/src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java b/src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java index ad3cdfa0..9b25fed4 100644 --- a/src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/errors/RestResponseEntityExceptionHandlerUnitTest.java @@ -187,7 +187,7 @@ class RestResponseEntityExceptionHandlerUnitTest { final var givenWebRequest = mock(WebRequest.class); // when - final var errorResponse = exceptionHandler.handleIbanAndBicExceptions(givenException, givenWebRequest); + final var errorResponse = exceptionHandler.handleValidationExceptions(givenException, givenWebRequest); // then assertThat(errorResponse.getBody().getStatusCode()).isEqualTo(400); diff --git a/src/test/java/net/hostsharing/hsadminng/hash/LinuxEtcShadowHashGeneratorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hash/LinuxEtcShadowHashGeneratorUnitTest.java new file mode 100644 index 00000000..c5abcc08 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hash/LinuxEtcShadowHashGeneratorUnitTest.java @@ -0,0 +1,51 @@ +package net.hostsharing.hsadminng.hash; + +import org.junit.jupiter.api.Test; + +import static net.hostsharing.hsadminng.hash.LinuxEtcShadowHashGenerator.Algorithm.SHA512; +import static net.hostsharing.hsadminng.hash.LinuxEtcShadowHashGenerator.hash; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class LinuxEtcShadowHashGeneratorUnitTest { + + final String GIVEN_PASSWORD = "given password"; + final String WRONG_PASSWORD = "wrong password"; + final String GIVEN_SALT = "0123456789abcdef"; + + // generated via mkpasswd for plaintext password GIVEN_PASSWORD (see above) + final String GIVEN_SHA512_HASH = "$6$ooei1HK6JXVaI7KC$sY5d9fEOr36hjh4CYwIKLMfRKL1539bEmbVCZ.zPiH0sv7jJVnoIXb5YEefEtoSM2WWgDi9hr7vXRe3Nw8zJP/"; + final String GIVEN_YESCRYPT_HASH = "$y$j9T$wgYACPmBXvlMg2MzeZA0p1$KXUzd28nG.67GhPnBZ3aZsNNA5bWFdL/dyG4wS0iRw7"; + + @Test + void verifiesPasswordAgainstSha512HashFromMkpasswd() { + hash(GIVEN_PASSWORD).verify(GIVEN_SHA512_HASH); // throws exception if wrong + } + + @Test + void verifiesPasswordAgainstYescryptHashFromMkpasswd() { + hash(GIVEN_PASSWORD).verify(GIVEN_YESCRYPT_HASH); // throws exception if wrong + } + + @Test + void verifiesHashedPasswordWithRandomSalt() { + final var hash = hash(GIVEN_PASSWORD).using(SHA512).withRandomSalt().generate(); + hash(GIVEN_PASSWORD).verify(hash); // throws exception if wrong + } + + @Test + void verifiesHashedPasswordWithGivenSalt() { + final var givenPasswordHash =hash(GIVEN_PASSWORD).using(SHA512).withSalt(GIVEN_SALT).generate(); + hash(GIVEN_PASSWORD).verify(givenPasswordHash); // throws exception if wrong + } + + @Test + void throwsExceptionForInvalidPassword() { + final var givenPasswordHash = hash(GIVEN_PASSWORD).using(SHA512).withRandomSalt().generate(); + + final var throwable = catchThrowable(() -> + hash(WRONG_PASSWORD).verify(givenPasswordHash) // throws exception if wrong); + ); + assertThat(throwable).hasMessage("invalid password"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorEntityUnitTest.java new file mode 100644 index 00000000..154e2b89 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/debitor/HsBookingDebitorEntityUnitTest.java @@ -0,0 +1,33 @@ +package net.hostsharing.hsadminng.hs.booking.debitor; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HsBookingDebitorEntityUnitTest { + + @Test + void toStringContainsDebitorNumberAndDefaultPrefix() { + final var given = HsBookingDebitorEntity.builder() + .debitorNumber(1234567) + .defaultPrefix("som") + .build(); + + final var result = given.toString(); + + assertThat(result).isEqualTo("booking-debitor(D-1234567: som)"); + } + + @Test + void toShortStringContainsDefaultPrefix() { + final var given = HsBookingDebitorEntity.builder() + .debitorNumber(1234567) + .defaultPrefix("som") + .build(); + + final var result = given.toShortString(); + + assertThat(result).isEqualTo("D-1234567"); + } + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/debitor/TestHsBookingDebitor.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/debitor/TestHsBookingDebitor.java new file mode 100644 index 00000000..2dcc6c3b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/debitor/TestHsBookingDebitor.java @@ -0,0 +1,13 @@ +package net.hostsharing.hsadminng.hs.booking.debitor; + +import lombok.experimental.UtilityClass; + + +@UtilityClass +public class TestHsBookingDebitor { + + public static final HsBookingDebitorEntity TEST_BOOKING_DEBITOR = HsBookingDebitorEntity.builder() + .debitorNumber(1234500) + .defaultPrefix("abc") + .build(); +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerAcceptanceTest.java index 0a92ff3f..5edc23af 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerAcceptanceTest.java @@ -4,23 +4,29 @@ import io.hypersistence.utils.hibernate.type.range.Range; import io.restassured.RestAssured; import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRepository; import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; +import org.junit.jupiter.api.TestMethodOrder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.transaction.annotation.Transactional; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; import java.time.LocalDate; +import java.util.List; import java.util.Map; import java.util.UUID; import static java.util.Map.entry; +import static java.util.Optional.ofNullable; import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; import static org.assertj.core.api.Assertions.assertThat; @@ -31,6 +37,7 @@ import static org.hamcrest.Matchers.matchesRegex; classes = { HsadminNgApplication.class, JpaAttempt.class } ) @Transactional +@TestClassOrder(ClassOrderer.OrderAnnotation.class) // fail early on fetching problems class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup { @LocalServerPort @@ -39,16 +46,17 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup @Autowired HsBookingItemRepository bookingItemRepo; + @Autowired + HsBookingProjectRepository projectRepo; + @Autowired HsOfficeDebitorRepository debitorRepo; @Autowired JpaAttempt jpaAttempt; - @PersistenceContext - EntityManager em; - @Nested + @Order(2) class ListBookingItems { @Test @@ -56,41 +64,45 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup // given context("superuser-alex@hostsharing.net"); - final var givenDebitor = debitorRepo.findDebitorByDebitorNumber(1000111).get(0); + final var givenProject = debitorRepo.findDebitorByDebitorNumber(1000111).stream() + .map(d -> projectRepo.findAllByDebitorUuid(d.getUuid())) + .flatMap(List::stream) + .findFirst() + .orElseThrow(); RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .port(port) .when() - .get("http://localhost/api/hs/booking/items?debitorUuid=" + givenDebitor.getUuid()) + .get("http://localhost/api/hs/booking/items?projectUuid=" + givenProject.getUuid()) .then().log().all().assertThat() .statusCode(200) .contentType("application/json") .body("", lenientlyEquals(""" [ + { + "type": "MANAGED_WEBSPACE", + "caption": "separate ManagedWebspace", + "validFrom": "2022-10-01", + "validTo": null, + "resources": { + "SSD": 100, + "Multi": 1, + "Daemons": 0, + "Traffic": 50 + } + }, { "type": "MANAGED_SERVER", - "caption": "some ManagedServer", + "caption": "separate ManagedServer", "validFrom": "2022-10-01", "validTo": null, "resources": { "RAM": 8, - "SDD": 512, + "SSD": 500, "CPUs": 2, - "Traffic": 42 - } - }, - { - "type": "CLOUD_SERVER", - "caption": "some CloudServer", - "validFrom": "2023-01-15", - "validTo": "2024-04-14", - "resources": { - "HDD": 1024, - "RAM": 4, - "CPUs": 2, - "Traffic": 42 + "Traffic": 500 } }, { @@ -99,10 +111,11 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup "validFrom": "2024-04-01", "validTo": null, "resources": { - "HDD": 10240, - "SDD": 10240, + "HDD": 10000, + "RAM": 32, + "SSD": 4000, "CPUs": 10, - "Traffic": 42 + "Traffic": 2000 } } ] @@ -112,13 +125,18 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup } @Nested + @Order(3) class AddBookingItem { @Test void globalAdmin_canAddBookingItem() { context.define("superuser-alex@hostsharing.net"); - final var givenDebitor = debitorRepo.findDebitorByDebitorNumber(1000111).get(0); + final var givenProject = debitorRepo.findDebitorByDebitorNumber(1000111).stream() + .map(d -> projectRepo.findAllByDebitorUuid(d.getUuid())) + .flatMap(List::stream) + .findFirst() + .orElseThrow(); final var location = RestAssured // @formatter:off .given() @@ -126,13 +144,16 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup .contentType(ContentType.JSON) .body(""" { - "debitorUuid": "%s", + "projectUuid": "{projectUuid}", "type": "MANAGED_SERVER", "caption": "some new booking", - "resources": { "CPUs": 12, "RAM": 4, "SSD": 100, "Traffic": 250 }, - "validFrom": "2022-10-13" + "validTo": "{validTo}", + "resources": { "CPUs": 12, "RAM": 4, "SSD": 100, "Traffic": 250 } } - """.formatted(givenDebitor.getUuid())) + """ + .replace("{projectUuid}", givenProject.getUuid().toString()) + .replace("{validTo}", LocalDate.now().plusMonths(1).toString()) + ) .port(port) .when() .post("http://localhost/api/hs/booking/items") @@ -143,11 +164,14 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup { "type": "MANAGED_SERVER", "caption": "some new booking", - "validFrom": "2022-10-13", - "validTo": null, + "validFrom": "{today}", + "validTo": "{todayPlus1Month}", "resources": { "CPUs": 12, "SSD": 100, "Traffic": 250 } } - """)) + """ + .replace("{today}", LocalDate.now().toString()) + .replace("{todayPlus1Month}", LocalDate.now().plusMonths(1).toString())) + ) .header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/booking/items/[^/]*")) .extract().header("Location"); // @formatter:on @@ -159,15 +183,18 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup } @Nested + @Order(1) + @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class GetBookingItem { @Test + @Order(1) void globalAdmin_canGetArbitraryBookingItem() { context.define("superuser-alex@hostsharing.net"); - final var givenBookingItemUuid = bookingItemRepo.findAll().stream() - .filter(bi -> bi.getDebitor().getDebitorNumber() == 1000111) - .filter(item -> item.getCaption().equals("some CloudServer")) - .findAny().orElseThrow().getUuid(); + final var givenBookingItemUuid = bookingItemRepo.findByCaption("separate ManagedWebspace").stream() + .filter(bi -> belongsToProject(bi, "D-1000111 default project")) + .map(HsBookingItemEntity::getUuid) + .findAny().orElseThrow(); RestAssured // @formatter:off .given() @@ -180,24 +207,26 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup .contentType("application/json") .body("", lenientlyEquals(""" { - "caption": "some CloudServer", - "validFrom": "2023-01-15", - "validTo": "2024-04-14", - "resources": { - "HDD": 1024, - "RAM": 4, - "CPUs": 2, - "Traffic": 42 + "type": "MANAGED_WEBSPACE", + "caption": "separate ManagedWebspace", + "validFrom": "2022-10-01", + "validTo": null, + "resources": { + "SSD": 100, + "Multi": 1, + "Daemons": 0, + "Traffic": 50 } } """)); // @formatter:on } @Test + @Order(2) void normalUser_canNotGetUnrelatedBookingItem() { context.define("superuser-alex@hostsharing.net"); - final var givenBookingItemUuid = bookingItemRepo.findAll().stream() - .filter(bi -> bi.getDebitor().getDebitorNumber() == 1000212) + final var givenBookingItemUuid = bookingItemRepo.findByCaption("separate ManagedServer").stream() + .filter(bi -> belongsToProject(bi, "D-1000212 default project")) .map(HsBookingItemEntity::getUuid) .findAny().orElseThrow(); @@ -212,45 +241,55 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup } @Test - void debitorAgentUser_canGetRelatedBookingItem() { + @Order(3) + void projectAdmin_canGetRelatedBookingItem() { context.define("superuser-alex@hostsharing.net"); - final var givenBookingItemUuid = bookingItemRepo.findAll().stream() - .filter(bi -> bi.getDebitor().getDebitorNumber() == 1000313) - .filter(item -> item.getCaption().equals("some CloudServer")) - .findAny().orElseThrow().getUuid(); + final var givenBookingItem = bookingItemRepo.findByCaption("separate ManagedServer").stream() + .filter(bi -> belongsToProject(bi, "D-1000313 default project")) + .findAny().orElseThrow(); RestAssured // @formatter:off .given() - .header("current-user", "person-TuckerJack@example.com") + .header("current-user", "superuser-alex@hostsharing.net") + .header("assumed-roles", "hs_booking_project#D-1000313-D-1000313defaultproject:ADMIN") .port(port) .when() - .get("http://localhost/api/hs/booking/items/" + givenBookingItemUuid) + .get("http://localhost/api/hs/booking/items/" + givenBookingItem.getUuid()) .then().log().all().assertThat() .statusCode(200) .contentType("application/json") .body("", lenientlyEquals(""" { - "caption": "some CloudServer", - "validFrom": "2023-01-15", - "validTo": "2024-04-14", + "type": "MANAGED_SERVER", + "caption": "separate ManagedServer", + "validFrom": "2022-10-01", + "validTo": null, "resources": { - "HDD": 1024, - "RAM": 4, + "RAM": 8, + "SSD": 500, "CPUs": 2, - "Traffic": 42 + "Traffic": 500 } } """)); // @formatter:on } + + private static boolean belongsToProject(final HsBookingItemEntity bi, final String projectCaption) { + return ofNullable(bi) + .map(HsBookingItemEntity::getProject) + .filter(bp -> bp.getCaption().equals(projectCaption)) + .isPresent(); + } } @Nested + @Order(4) class PatchBookingItem { @Test void globalAdmin_canPatchAllUpdatablePropertiesOfBookingItem() { - final var givenBookingItem = givenSomeBookingItem(1000111, MANAGED_WEBSPACE, + final var givenBookingItem = givenSomeNewBookingItem("D-1000111 default project", MANAGED_WEBSPACE, resource("HDD", 100), resource("SSD", 50), resource("Traffic", 250)); RestAssured // @formatter:off @@ -290,7 +329,7 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup context.define("superuser-alex@hostsharing.net"); assertThat(bookingItemRepo.findByUuid(givenBookingItem.getUuid())).isPresent().get() .matches(mandate -> { - assertThat(mandate.getDebitor().toString()).isEqualTo("debitor(D-1000111: rel(anchor='LP First GmbH', type='DEBITOR', holder='LP First GmbH'), fir)"); + assertThat(mandate.getProject().getDebitor().toString()).isEqualTo("booking-debitor(D-1000111: fir)"); assertThat(mandate.getValidFrom()).isEqualTo("2022-11-01"); assertThat(mandate.getValidTo()).isEqualTo("2022-12-31"); return true; @@ -299,12 +338,13 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup } @Nested + @Order(5) class DeleteBookingItem { @Test void globalAdmin_canDeleteArbitraryBookingItem() { context.define("superuser-alex@hostsharing.net"); - final var givenBookingItem = givenSomeBookingItem(1000111, MANAGED_WEBSPACE, + final var givenBookingItem = givenSomeNewBookingItem("D-1000111 default project", MANAGED_WEBSPACE, resource("HDD", 100), resource("SSD", 50), resource("Traffic", 250)); RestAssured // @formatter:off @@ -323,7 +363,7 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup @Test void normalUser_canNotDeleteUnrelatedBookingItem() { context.define("superuser-alex@hostsharing.net"); - final var givenBookingItem = givenSomeBookingItem(1000111, MANAGED_WEBSPACE, + final var givenBookingItem = givenSomeNewBookingItem("D-1000111 default project", MANAGED_WEBSPACE, resource("HDD", 100), resource("SSD", 50), resource("Traffic", 250)); RestAssured // @formatter:off @@ -341,14 +381,15 @@ class HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup } @SafeVarargs - private HsBookingItemEntity givenSomeBookingItem(final int debitorNumber, + private HsBookingItemEntity givenSomeNewBookingItem(final String projectCaption, final HsBookingItemType hsBookingItemType, final Map.Entry... resources) { return jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net"); - final var givenDebitor = debitorRepo.findDebitorByDebitorNumber(debitorNumber).get(0); + final var givenProject = projectRepo.findByCaption(projectCaption).stream() + .findAny().orElseThrow(); final var newBookingItem = HsBookingItemEntity.builder() .uuid(UUID.randomUUID()) - .debitor(givenDebitor) + .project(givenProject) .type(hsBookingItemType) .caption("some test-booking") .resources(Map.ofEntries(resources)) diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerRestTest.java new file mode 100644 index 00000000..0fb0f6f0 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemControllerRestTest.java @@ -0,0 +1,171 @@ +package net.hostsharing.hsadminng.hs.booking.item; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRepository; +import net.hostsharing.hsadminng.mapper.Mapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; +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; +import java.time.LocalDate; +import java.util.Map; +import java.util.UUID; + +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; +import static org.hamcrest.Matchers.matchesRegex; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(HsBookingItemController.class) +@Import(Mapper.class) +@RunWith(SpringRunner.class) +class HsBookingItemControllerRestTest { + + @Autowired + MockMvc mockMvc; + + @MockBean + Context contextMock; + + @Mock + EntityManager em; + + @MockBean + EntityManagerFactory emf; + + @MockBean + HsBookingProjectRepository bookingProjectRepo; + + @MockBean + HsBookingItemRepository bookingItemRepo; + + @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); + } + + @Nested + class AddBookingItem { + + @Test + void globalAdmin_canAddValidBookingItem() throws Exception { + + final var givenProjectUuid = UUID.randomUUID(); + + // given + when(em.find(HsBookingProjectEntity.class, givenProjectUuid)).thenAnswer(invocation -> + HsBookingProjectEntity.builder() + .uuid(invocation.getArgument(1)) + .build() + ); + when(bookingItemRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/hs/booking/items") + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "projectUuid": "{projectUuid}", + "type": "MANAGED_SERVER", + "caption": "some new booking", + "validTo": "{validTo}", + "garbage": "should not be accepted", + "resources": { "CPUs": 12, "RAM": 4, "SSD": 100, "Traffic": 250 } + } + """ + .replace("{projectUuid}", givenProjectUuid.toString()) + .replace("{validTo}", LocalDate.now().plusMonths(1).toString()) + ) + .accept(MediaType.APPLICATION_JSON)) + + // then + .andExpect(status().isCreated()) + .andExpect(jsonPath("$", lenientlyEquals(""" + { + "type": "MANAGED_SERVER", + "caption": "some new booking", + "validFrom": "{today}", + "validTo": "{todayPlus1Month}", + "resources": { "CPUs": 12, "SSD": 100, "Traffic": 250 } + } + """ + .replace("{today}", LocalDate.now().toString()) + .replace("{todayPlus1Month}", LocalDate.now().plusMonths(1).toString())) + )) + .andExpect(header().string("Location", matchesRegex("http://localhost/api/hs/booking/items/[^/]*"))); + } + + @Test + void globalAdmin_canNotAddInvalidBookingItem() throws Exception { + + final var givenProjectUuid = UUID.randomUUID(); + + // given + when(em.find(HsBookingProjectEntity.class, givenProjectUuid)).thenAnswer(invocation -> + HsBookingProjectEntity.builder() + .uuid(invocation.getArgument(1)) + .build() + ); + when(bookingItemRepo.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + mockMvc.perform(MockMvcRequestBuilders + .post("/api/hs/booking/items") + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "projectUuid": "{projectUuid}", + "type": "MANAGED_SERVER", + "caption": "some new booking", + "validFrom": "{validFrom}", + "resources": { "CPUs": 12, "RAM": 4, "SSD": 100, "Traffic": 250 } + } + """ + .replace("{projectUuid}", givenProjectUuid.toString()) + .replace("{validFrom}", LocalDate.now().plusMonths(1).toString()) + ) + .accept(MediaType.APPLICATION_JSON)) + + // then + // TODO.test: MockMvc does not seem to validate additionalProperties=false + // .andExpect(status().is4xxClientError()) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$", lenientlyEquals(""" + { + "type": "MANAGED_SERVER", + "caption": "some new booking", + "validFrom": "{today}", + "validTo": null, + "resources": { "CPUs": 12, "SSD": 100, "Traffic": 250 } + } + """ + .replace("{today}", LocalDate.now().toString()) + .replace("{todayPlus1Month}", LocalDate.now().plusMonths(1).toString())) + )) + .andExpect(header().string("Location", matchesRegex("http://localhost/api/hs/booking/items/[^/]*"))); + } + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityPatcherUnitTest.java index b7ff8ab4..ca179fc3 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityPatcherUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityPatcherUnitTest.java @@ -17,7 +17,7 @@ import java.util.Map; import java.util.UUID; import java.util.stream.Stream; -import static net.hostsharing.hsadminng.hs.office.debitor.TestHsOfficeDebitor.TEST_DEBITOR; +import static net.hostsharing.hsadminng.hs.booking.project.TestHsBookingProject.TEST_PROJECT; import static net.hostsharing.hsadminng.mapper.PatchMap.entry; import static net.hostsharing.hsadminng.mapper.PatchMap.patchMap; import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; @@ -44,11 +44,11 @@ class HsBookingItemEntityPatcherUnitTest extends PatchUnitTestBase< private static final Map PATCH_RESOURCES = patchMap( entry("CPU", 2), entry("HDD", null), - entry("SDD", 256) + entry("SSD", 256) ); private static final Map PATCHED_RESOURCES = patchMap( entry("CPU", 2), - entry("SDD", 256), + entry("SSD", 256), entry("MEM", 64) ); @@ -70,7 +70,7 @@ class HsBookingItemEntityPatcherUnitTest extends PatchUnitTestBase< protected HsBookingItemEntity newInitialEntity() { final var entity = new HsBookingItemEntity(); entity.setUuid(INITIAL_BOOKING_ITEM_UUID); - entity.setDebitor(TEST_DEBITOR); + entity.setProject(TEST_PROJECT); entity.getResources().putAll(KeyValueMap.from(INITIAL_RESOURCES)); entity.setCaption(INITIAL_CAPTION); entity.setValidity(Range.closedInfinite(GIVEN_VALID_FROM)); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityUnitTest.java index 72d373e0..258b55b7 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemEntityUnitTest.java @@ -1,12 +1,16 @@ package net.hostsharing.hsadminng.hs.booking.item; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import java.time.LocalDate; +import java.time.Month; import java.util.Map; import static java.util.Map.entry; -import static net.hostsharing.hsadminng.hs.office.debitor.TestHsOfficeDebitor.TEST_DEBITOR; +import static net.hostsharing.hsadminng.hs.booking.project.TestHsBookingProject.TEST_PROJECT; import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange; import static org.assertj.core.api.Assertions.assertThat; @@ -14,8 +18,10 @@ class HsBookingItemEntityUnitTest { public static final LocalDate GIVEN_VALID_FROM = LocalDate.parse("2020-01-01"); public static final LocalDate GIVEN_VALID_TO = LocalDate.parse("2030-12-31"); + private MockedStatic localDateMockedStatic = Mockito.mockStatic(LocalDate.class, Mockito.CALLS_REAL_METHODS); + final HsBookingItemEntity givenBookingItem = HsBookingItemEntity.builder() - .debitor(TEST_DEBITOR) + .project(TEST_PROJECT) .type(HsBookingItemType.CLOUD_SERVER) .caption("some caption") .resources(Map.ofEntries( @@ -25,18 +31,36 @@ class HsBookingItemEntityUnitTest { .validity(toPostgresDateRange(GIVEN_VALID_FROM, GIVEN_VALID_TO)) .build(); + @AfterEach + void tearDown() { + localDateMockedStatic.close(); + } + + @Test + void validityStartsToday() { + // given + final var fakedToday = LocalDate.of(2024, Month.MAY, 1); + localDateMockedStatic.when(LocalDate::now).thenReturn(fakedToday); + + // when + final var newBookingItem = HsBookingItemEntity.builder().build(); + + // then + assertThat(newBookingItem.getValidity().toString()).isEqualTo("Range{lower=2024-05-01, upper=null, mask=82, clazz=class java.time.LocalDate}"); + } + @Test void toStringContainsAllPropertiesAndResourcesSortedByKey() { final var result = givenBookingItem.toString(); - assertThat(result).isEqualTo("HsBookingItemEntity(D-1000100, CLOUD_SERVER, [2020-01-01,2031-01-01), some caption, { CPUs: 2, HDD-storage: 2048, SSD-storage: 512 })"); + assertThat(result).isEqualToIgnoringWhitespace("HsBookingItemEntity(D-1234500:test project, CLOUD_SERVER, [2020-01-01,2031-01-01), some caption, { \"CPUs\": 2, \"HDD-storage\": 2048, \"SSD-storage\": 512 })"); } @Test void toShortStringContainsOnlyMemberNumberAndCaption() { final var result = givenBookingItem.toShortString(); - assertThat(result).isEqualTo("D-1000100:some caption"); + assertThat(result).isEqualTo("D-1234500:test project:some caption"); } @Test diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepositoryIntegrationTest.java index c76d30df..5e32e23d 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/HsBookingItemRepositoryIntegrationTest.java @@ -2,10 +2,11 @@ package net.hostsharing.hsadminng.hs.booking.item; import io.hypersistence.utils.hibernate.type.range.Range; import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRepository; import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; @@ -29,7 +30,7 @@ import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGE import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; -import static net.hostsharing.hsadminng.rbac.test.Array.fromFormatted; +import static net.hostsharing.hsadminng.mapper.Array.fromFormatted; import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @@ -40,6 +41,9 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup @Autowired HsBookingItemRepository bookingItemRepo; + @Autowired + HsBookingProjectRepository projectRepo; + @Autowired HsOfficeDebitorRepository debitorRepo; @@ -67,11 +71,12 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup context("superuser-alex@hostsharing.net"); final var count = bookingItemRepo.count(); final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0); + final var givenProject = projectRepo.findAllByDebitorUuid(givenDebitor.getUuid()).get(0); // when final var result = attempt(em, () -> { final var newBookingItem = HsBookingItemEntity.builder() - .debitor(givenDebitor) + .project(givenProject) .type(HsBookingItemType.CLOUD_SERVER) .caption("some new booking item") .validity(Range.closedOpen( @@ -92,15 +97,14 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup // given context("superuser-alex@hostsharing.net"); final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()).stream() - .map(s -> s.replace("hs_office_", "")) - .toList(); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()); // when attempt(em, () -> { final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0); + final var givenProject = projectRepo.findAllByDebitorUuid(givenDebitor.getUuid()).get(0); final var newBookingItem = HsBookingItemEntity.builder() - .debitor(givenDebitor) + .project(givenProject) .type(MANAGED_WEBSPACE) .caption("some new booking item") .validity(Range.closedOpen( @@ -113,35 +117,32 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup final var all = rawRoleRepo.findAll(); assertThat(distinctRoleNamesOf(all)).containsExactlyInAnyOrder(Array.from( initialRoleNames, - "hs_booking_item#D-1000111-somenewbookingitem:ADMIN", - "hs_booking_item#D-1000111-somenewbookingitem:AGENT", - "hs_booking_item#D-1000111-somenewbookingitem:OWNER", - "hs_booking_item#D-1000111-somenewbookingitem:TENANT")); + "hs_booking_item#somenewbookingitem:ADMIN", + "hs_booking_item#somenewbookingitem:AGENT", + "hs_booking_item#somenewbookingitem:OWNER", + "hs_booking_item#somenewbookingitem:TENANT")); assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())) - .map(s -> s.replace("hs_office_", "")) .containsExactlyInAnyOrder(fromFormatted( initialGrantNames, // global-admin - "{ grant perm:hs_booking_item#D-1000111-somenewbookingitem:DELETE to role:global#global:ADMIN by system and assume }", + "{ grant perm:hs_booking_item#somenewbookingitem:INSERT>hs_booking_item to role:hs_booking_item#somenewbookingitem:ADMIN by system and assume }", + "{ grant perm:hs_booking_item#somenewbookingitem:DELETE to role:global#global:ADMIN by system and assume }", // owner - "{ grant role:hs_booking_item#D-1000111-somenewbookingitem:OWNER to role:relation#FirstGmbH-with-DEBITOR-FirstGmbH:AGENT by system and assume }", + "{ grant role:hs_booking_item#somenewbookingitem:OWNER to role:hs_booking_project#D-1000111-D-1000111defaultproject:AGENT by system and assume }", // admin - "{ grant perm:hs_booking_item#D-1000111-somenewbookingitem:UPDATE to role:hs_booking_item#D-1000111-somenewbookingitem:ADMIN by system and assume }", - "{ grant role:hs_booking_item#D-1000111-somenewbookingitem:ADMIN to role:hs_booking_item#D-1000111-somenewbookingitem:OWNER by system and assume }", - "{ grant perm:hs_booking_item#D-1000111-somenewbookingitem:INSERT>hs_hosting_asset to role:hs_booking_item#D-1000111-somenewbookingitem:AGENT by system and assume }", + "{ grant perm:hs_booking_item#somenewbookingitem:UPDATE to role:hs_booking_item#somenewbookingitem:ADMIN by system and assume }", + "{ grant role:hs_booking_item#somenewbookingitem:ADMIN to role:hs_booking_item#somenewbookingitem:OWNER by system and assume }", // agent - "{ grant role:hs_booking_item#D-1000111-somenewbookingitem:ADMIN to role:relation#FirstGmbH-with-DEBITOR-FirstGmbH:AGENT by system and assume }", - "{ grant role:hs_booking_item#D-1000111-somenewbookingitem:AGENT to role:hs_booking_item#D-1000111-somenewbookingitem:ADMIN by system and assume }", + "{ grant role:hs_booking_item#somenewbookingitem:AGENT to role:hs_booking_item#somenewbookingitem:ADMIN by system and assume }", // tenant - "{ grant role:hs_booking_item#D-1000111-somenewbookingitem:TENANT to role:hs_booking_item#D-1000111-somenewbookingitem:AGENT by system and assume }", - "{ grant perm:hs_booking_item#D-1000111-somenewbookingitem:SELECT to role:hs_booking_item#D-1000111-somenewbookingitem:TENANT by system and assume }", - "{ grant role:relation#FirstGmbH-with-DEBITOR-FirstGmbH:TENANT to role:hs_booking_item#D-1000111-somenewbookingitem:TENANT by system and assume }", - + "{ grant role:hs_booking_item#somenewbookingitem:TENANT to role:hs_booking_item#somenewbookingitem:AGENT by system and assume }", + "{ grant perm:hs_booking_item#somenewbookingitem:SELECT to role:hs_booking_item#somenewbookingitem:TENANT by system and assume }", + "{ grant role:hs_booking_project#D-1000111-D-1000111defaultproject:TENANT to role:hs_booking_item#somenewbookingitem:TENANT by system and assume }", null)); } @@ -158,35 +159,43 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup public void globalAdmin_withoutAssumedRole_canViewAllBookingItemsOfArbitraryDebitor() { // given context("superuser-alex@hostsharing.net"); - final var debitorUuid = debitorRepo.findDebitorByDebitorNumber(1000212).stream() + final var projectUuid = debitorRepo.findDebitorByDebitorNumber(1000212).stream() + .map(d -> projectRepo.findAllByDebitorUuid(d.getUuid())) + .flatMap(List::stream) .findAny().orElseThrow().getUuid(); // when - final var result = bookingItemRepo.findAllByDebitorUuid(debitorUuid); + final var result = bookingItemRepo.findAllByProjectUuid(projectUuid); // then allTheseBookingItemsAreReturned( result, - "HsBookingItemEntity(D-1000212, MANAGED_SERVER, [2022-10-01,), some ManagedServer, { CPUs: 2, RAM: 8, SDD: 512, Traffic: 42 })", - "HsBookingItemEntity(D-1000212, CLOUD_SERVER, [2023-01-15,2024-04-15), some CloudServer, { CPUs: 2, HDD: 1024, RAM: 4, Traffic: 42 })", - "HsBookingItemEntity(D-1000212, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10240, SDD: 10240, Traffic: 42 })"); + "HsBookingItemEntity(D-1000212:D-1000212 default project, MANAGED_WEBSPACE, [2022-10-01,), separate ManagedWebspace, { Daemons: 0, Multi: 1, SSD: 100, Traffic: 50 } )", + "HsBookingItemEntity(D-1000212:D-1000212 default project, MANAGED_SERVER, [2022-10-01,), separate ManagedServer, { CPUs: 2, RAM: 8, SSD: 500, Traffic: 500 } )", + "HsBookingItemEntity(D-1000212:D-1000212 default project, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10000, RAM: 32, SSD: 4000, Traffic: 2000 } )"); + assertThat(result.stream().filter(bi -> bi.getRelatedHostingAsset()!=null).findAny()) + .as("at least one relatedProject expected, but none found => fetching relatedProject does not work") + .isNotEmpty(); } @Test public void normalUser_canViewOnlyRelatedBookingItems() { // given: context("person-FirbySusan@example.com"); - final var debitorUuid = debitorRepo.findDebitorByDebitorNumber(1000111).stream().findAny().orElseThrow().getUuid(); + final var projectUuid = debitorRepo.findDebitorByDebitorNumber(1000111).stream() + .map(d -> projectRepo.findAllByDebitorUuid(d.getUuid())) + .flatMap(List::stream) + .findAny().orElseThrow().getUuid(); // when: - final var result = bookingItemRepo.findAllByDebitorUuid(debitorUuid); + final var result = bookingItemRepo.findAllByProjectUuid(projectUuid); // then: exactlyTheseBookingItemsAreReturned( result, - "HsBookingItemEntity(D-1000111, MANAGED_SERVER, [2022-10-01,), some ManagedServer, { CPUs: 2, RAM: 8, SDD: 512, Traffic: 42 })", - "HsBookingItemEntity(D-1000111, CLOUD_SERVER, [2023-01-15,2024-04-15), some CloudServer, { CPUs: 2, HDD: 1024, RAM: 4, Traffic: 42 })", - "HsBookingItemEntity(D-1000111, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10240, SDD: 10240, Traffic: 42 })"); + "HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_WEBSPACE, [2022-10-01,), separate ManagedWebspace, { Daemons: 0, Multi: 1, SSD: 100, Traffic: 50 } )", + "HsBookingItemEntity(D-1000111:D-1000111 default project, MANAGED_SERVER, [2022-10-01,), separate ManagedServer, { CPUs: 2, RAM: 8, SSD: 500, Traffic: 500 } )", + "HsBookingItemEntity(D-1000111:D-1000111 default project, PRIVATE_CLOUD, [2024-04-01,), some PrivateCloud, { CPUs: 10, HDD: 10000, RAM: 32, SSD: 4000, Traffic: 2000 } )"); } } @@ -196,7 +205,7 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup @Test public void hostsharingAdmin_canUpdateArbitraryBookingItem() { // given - final var givenBookingItemUuid = givenSomeTemporaryBookingItem(1000111).getUuid(); + final var givenBookingItemUuid = givenSomeTemporaryBookingItem("D-1000111 default project").getUuid(); // when final var result = jpaAttempt.transacted(() -> { @@ -221,7 +230,9 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup private void assertThatBookingItemActuallyInDatabase(final HsBookingItemEntity saved) { final var found = bookingItemRepo.findByUuid(saved.getUuid()); assertThat(found).isNotEmpty().get().isNotSameAs(saved) - .extracting(Object::toString).isEqualTo(saved.toString()); + .extracting(HsBookingItemEntity::getResources) + .extracting(Object::toString) + .isEqualTo(saved.getResources().toString()); } } @@ -232,7 +243,7 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup public void globalAdmin_withoutAssumedRole_canDeleteAnyBookingItem() { // given context("superuser-alex@hostsharing.net", null); - final var givenBookingItem = givenSomeTemporaryBookingItem(1000111); + final var givenBookingItem = givenSomeTemporaryBookingItem("D-1000111 default project"); // when final var result = jpaAttempt.transacted(() -> { @@ -252,7 +263,7 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup public void nonGlobalAdmin_canNotDeleteTheirRelatedBookingItem() { // given context("superuser-alex@hostsharing.net", null); - final var givenBookingItem = givenSomeTemporaryBookingItem(1000111); + final var givenBookingItem = givenSomeTemporaryBookingItem("D-1000111 default project"); // when final var result = jpaAttempt.transacted(() -> { @@ -278,7 +289,7 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup context("superuser-alex@hostsharing.net"); final var initialRoleNames = Array.from(distinctRoleNamesOf(rawRoleRepo.findAll())); final var initialGrantNames = Array.from(distinctGrantDisplaysOf(rawGrantRepo.findAll())); - final var givenBookingItem = givenSomeTemporaryBookingItem(1000111); + final var givenBookingItem = givenSomeTemporaryBookingItem("D-1000111 default project"); // when final var result = jpaAttempt.transacted(() -> { @@ -313,12 +324,13 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup "[creating booking-item test-data 1000313, hs_booking_item, INSERT]"); } - private HsBookingItemEntity givenSomeTemporaryBookingItem(final int debitorNumber) { + private HsBookingItemEntity givenSomeTemporaryBookingItem(final String projectCaption) { return jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); - final var givenDebitor = debitorRepo.findDebitorByDebitorNumber(debitorNumber).get(0); + final var givenProject = projectRepo.findByCaption(projectCaption).stream() + .findAny().orElseThrow(); final var newBookingItem = HsBookingItemEntity.builder() - .debitor(givenDebitor) + .project(givenProject) .type(MANAGED_SERVER) .caption("some temp booking item") .validity(Range.closedOpen( @@ -336,13 +348,17 @@ class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup final List actualResult, final String... bookingItemNames) { assertThat(actualResult) - .extracting(bookingItemEntity -> bookingItemEntity.toString()) + .extracting(HsBookingItemEntity::toString) + .extracting(string-> string.replaceAll("\\s+", " ")) + .extracting(string-> string.replaceAll("\"", "")) .containsExactlyInAnyOrder(bookingItemNames); } void allTheseBookingItemsAreReturned(final List actualResult, final String... bookingItemNames) { assertThat(actualResult) - .extracting(bookingItemEntity -> bookingItemEntity.toString()) + .extracting(HsBookingItemEntity::toString) + .extracting(string -> string.replaceAll("\\s+", " ")) + .extracting(string -> string.replaceAll("\"", "")) .contains(bookingItemNames); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/TestHsBookingItem.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/TestHsBookingItem.java index 1706cac4..bcb2baac 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/TestHsBookingItem.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/TestHsBookingItem.java @@ -7,18 +7,46 @@ import java.time.LocalDate; import java.util.Map; import static java.util.Map.entry; -import static net.hostsharing.hsadminng.hs.office.debitor.TestHsOfficeDebitor.TEST_DEBITOR; +import static net.hostsharing.hsadminng.hs.booking.project.TestHsBookingProject.TEST_PROJECT; @UtilityClass public class TestHsBookingItem { - public static final HsBookingItemEntity TEST_BOOKING_ITEM = HsBookingItemEntity.builder() - .debitor(TEST_DEBITOR) - .caption("test booking item") + public static final HsBookingItemEntity TEST_CLOUD_SERVER_BOOKING_ITEM = HsBookingItemEntity.builder() + .project(TEST_PROJECT) + .type(HsBookingItemType.CLOUD_SERVER) + .caption("test cloud server booking item") .resources(Map.ofEntries( - entry("someThing", 1), - entry("anotherThing", "blue") + entry("CPUs", 2), + entry("RAM", 4), + entry("SSD", 50), + entry("Traffic", 250) )) .validity(Range.closedInfinite(LocalDate.of(2020, 1, 15))) .build(); + + public static final HsBookingItemEntity TEST_MANAGED_SERVER_BOOKING_ITEM = HsBookingItemEntity.builder() + .project(TEST_PROJECT) + .type(HsBookingItemType.MANAGED_SERVER) + .caption("test project booking item") + .resources(Map.ofEntries( + entry("CPUs", 2), + entry("RAM", 4), + entry("SSD", 50), + entry("Traffic", 250) + )) + .validity(Range.closedInfinite(LocalDate.of(2020, 1, 15))) + .build(); + + public static final HsBookingItemEntity TEST_MANAGED_WEBSPACE_BOOKING_ITEM = HsBookingItemEntity.builder() + .parentItem(TEST_MANAGED_SERVER_BOOKING_ITEM) + .type(HsBookingItemType.MANAGED_WEBSPACE) + .caption("test managed webspace item") + .resources(Map.ofEntries( + entry("SSD", 50), + entry("Traffic", 250) + )) + .validity(Range.closedInfinite(LocalDate.of(2020, 1, 15))) + .build(); + } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorUnitTest.java new file mode 100644 index 00000000..e784edec --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorUnitTest.java @@ -0,0 +1,55 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectEntity; +import org.junit.jupiter.api.Test; + +import jakarta.validation.ValidationException; + +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.PRIVATE_CLOUD; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class HsBookingItemEntityValidatorUnitTest { + final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder() + .debitorNumber(12345) + .build(); + final HsBookingProjectEntity project = HsBookingProjectEntity.builder() + .debitor(debitor) + .caption("test project") + .build(); + + @Test + void validThrowsException() { + // given + final var cloudServerBookingItemEntity = HsBookingItemEntity.builder() + .type(CLOUD_SERVER) + .project(project) + .caption("Test-Server") + .build(); + + // when + final var result = catchThrowable( ()-> HsBookingItemEntityValidatorRegistry.validated(cloudServerBookingItemEntity)); + + // then + assertThat(result).isInstanceOf(ValidationException.class) + .hasMessageContaining( + "'D-12345:test project:Test-Server.resources.CPUs' is required but missing", + "'D-12345:test project:Test-Server.resources.RAM' is required but missing", + "'D-12345:test project:Test-Server.resources.SSD' is required but missing", + "'D-12345:test project:Test-Server.resources.Traffic' is required but missing"); + } + + @Test + void listsTypes() { + // when + final var result = HsBookingItemEntityValidatorRegistry.types(); + + // then + assertThat(result).containsExactlyInAnyOrder(PRIVATE_CLOUD, CLOUD_SERVER, MANAGED_SERVER, MANAGED_WEBSPACE); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorsUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorsUnitTest.java deleted file mode 100644 index 741d7c1e..00000000 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsBookingItemEntityValidatorsUnitTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package net.hostsharing.hsadminng.hs.booking.item.validators; - -import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; -import org.junit.jupiter.api.Test; - -import jakarta.validation.ValidationException; - -import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_SERVER; -import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; -import static net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidators.valid; -import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.CLOUD_SERVER; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.catchThrowable; - -class HsBookingItemEntityValidatorsUnitTest { - - @Test - void validThrowsException() { - // given - final var cloudServerBookingItemEntity = HsBookingItemEntity.builder() - .type(CLOUD_SERVER) - .build(); - - // when - final var result = catchThrowable( ()-> valid(cloudServerBookingItemEntity) ); - - // then - assertThat(result).isInstanceOf(ValidationException.class) - .hasMessageContaining( - "'resources.CPUs' is required but missing", - "'resources.RAM' is required but missing", - "'resources.SSD' is required but missing", - "'resources.Traffic' is required but missing"); - } - - @Test - void listsTypes() { - // when - final var result = HsBookingItemEntityValidators.types(); - - // then - assertThat(result).containsExactlyInAnyOrder(CLOUD_SERVER, MANAGED_SERVER, MANAGED_WEBSPACE); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidatorUnitTest.java index e15b95d7..b5307cd7 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsCloudServerBookingItemValidatorUnitTest.java @@ -1,23 +1,37 @@ package net.hostsharing.hsadminng.hs.booking.item.validators; +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectEntity; import org.junit.jupiter.api.Test; import java.util.Map; +import static java.util.List.of; import static java.util.Map.entry; +import static java.util.Map.ofEntries; import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.CLOUD_SERVER; -import static net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidators.forType; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.PRIVATE_CLOUD; import static org.assertj.core.api.Assertions.assertThat; class HsCloudServerBookingItemValidatorUnitTest { + final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder() + .debitorNumber(12345) + .build(); + final HsBookingProjectEntity project = HsBookingProjectEntity.builder() + .debitor(debitor) + .caption("Test-Project") + .build(); + @Test void validatesProperties() { // given - final var validator = HsBookingItemEntityValidators.forType(CLOUD_SERVER); final var cloudServerBookingItemEntity = HsBookingItemEntity.builder() .type(CLOUD_SERVER) + .project(project) + .caption("Test-Server") .resources(Map.ofEntries( entry("CPUs", 2), entry("RAM", 25), @@ -28,24 +42,78 @@ class HsCloudServerBookingItemValidatorUnitTest { .build(); // when - final var result = validator.validate(cloudServerBookingItemEntity); + final var result = HsBookingItemEntityValidatorRegistry.doValidate(cloudServerBookingItemEntity); // then - assertThat(result).containsExactly("'resources.SLA-EMail' is not expected but is set to 'true'"); + assertThat(result).containsExactly("'D-12345:Test-Project:Test-Server.resources.SLA-EMail' is not expected but is set to 'true'"); } @Test void containsAllValidations() { // when - final var validator = forType(CLOUD_SERVER); + final var validator = HsBookingItemEntityValidatorRegistry.forType(CLOUD_SERVER); // then assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( - "{type=integer, propertyName=CPUs, required=true, unit=null, min=1, max=32, step=null}", - "{type=integer, propertyName=RAM, required=true, unit=GB, min=1, max=128, step=null}", - "{type=integer, propertyName=SSD, required=true, unit=GB, min=25, max=1000, step=25}", - "{type=integer, propertyName=HDD, required=false, unit=GB, min=0, max=4000, step=250}", - "{type=integer, propertyName=Traffic, required=true, unit=GB, min=250, max=10000, step=250}", - "{type=enumeration, propertyName=SLA-Infrastructure, required=false, values=[BASIC, EXT8H, EXT4H, EXT2H]}"); + "{type=boolean, propertyName=active, defaultValue=true}", + "{type=integer, propertyName=CPUs, min=1, max=32, required=true}", + "{type=integer, propertyName=RAM, unit=GB, min=1, max=128, required=true}", + "{type=integer, propertyName=SSD, unit=GB, min=0, max=1000, step=25, required=true}", + "{type=integer, propertyName=HDD, unit=GB, min=0, max=4000, step=250, defaultValue=0}", + "{type=integer, propertyName=Traffic, unit=GB, min=250, max=10000, step=250, required=true}", + "{type=enumeration, propertyName=SLA-Infrastructure, values=[BASIC, EXT8H, EXT4H, EXT2H]}"); + } + + @Test + void validatesExceedingPropertyTotals() { + // given + final var subCloudServerBookingItemEntity = HsBookingItemEntity.builder() + .type(CLOUD_SERVER) + .caption("Test Cloud-Server") + .resources(ofEntries( + entry("CPUs", 2), + entry("RAM", 10), + entry("SSD", 50), + entry("Traffic", 2500) + )) + .build(); + final HsBookingItemEntity subManagedServerBookingItemEntity = HsBookingItemEntity.builder() + .type(MANAGED_SERVER) + .caption("Test Managed-Server") + .resources(ofEntries( + entry("CPUs", 3), + entry("RAM", 20), + entry("SSD", 100), + entry("Traffic", 3000) + )) + .build(); + final var privateCloudBookingItemEntity = HsBookingItemEntity.builder() + .type(PRIVATE_CLOUD) + .project(project) + .caption("Test Cloud") + .resources(ofEntries( + entry("CPUs", 4), + entry("RAM", 20), + entry("SSD", 100), + entry("Traffic", 5000) + )) + .subBookingItems(of( + subManagedServerBookingItemEntity, + subCloudServerBookingItemEntity + )) + .build(); + subManagedServerBookingItemEntity.setParentItem(privateCloudBookingItemEntity); + subCloudServerBookingItemEntity.setParentItem(privateCloudBookingItemEntity); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(subCloudServerBookingItemEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'D-12345:Test-Project:Test Cloud.resources.CPUs' maximum total is 4, but actual total CPUs is 5", + "'D-12345:Test-Project:Test Cloud.resources.RAM' maximum total is 20 GB, but actual total RAM is 30 GB", + "'D-12345:Test-Project:Test Cloud.resources.SSD' maximum total is 100 GB, but actual total SSD is 150 GB", + "'D-12345:Test-Project:Test Cloud.resources.Traffic' maximum total is 5000 GB, but actual total Traffic is 5500 GB" + ); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedServerBookingItemValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedServerBookingItemValidatorUnitTest.java index 5f2bdfc3..5f95e598 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedServerBookingItemValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedServerBookingItemValidatorUnitTest.java @@ -1,56 +1,227 @@ package net.hostsharing.hsadminng.hs.booking.item.validators; +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; import org.junit.jupiter.api.Test; +import java.util.Collection; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import static java.util.Arrays.stream; +import static java.util.List.of; import static java.util.Map.entry; +import static java.util.Map.ofEntries; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.CLOUD_SERVER; import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_SERVER; -import static net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidators.forType; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.PRIVATE_CLOUD; import static org.assertj.core.api.Assertions.assertThat; class HsManagedServerBookingItemValidatorUnitTest { + final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder() + .debitorNumber(12345) + .build(); + final HsBookingProjectEntity project = HsBookingProjectEntity.builder() + .debitor(debitor) + .caption("Test-Project") + .build(); + @Test void validatesProperties() { // given - final var validator = HsBookingItemEntityValidators.forType(MANAGED_SERVER); final var mangedServerBookingItemEntity = HsBookingItemEntity.builder() .type(MANAGED_SERVER) + .project(project) .resources(Map.ofEntries( entry("CPUs", 2), entry("RAM", 25), entry("SSD", 25), entry("Traffic", 250), + entry("SLA-Platform", "BASIC"), entry("SLA-EMail", true) )) .build(); // when - final var result = validator.validate(mangedServerBookingItemEntity); + final var result = HsBookingItemEntityValidatorRegistry.doValidate(mangedServerBookingItemEntity); // then - assertThat(result).containsExactly("'resources.SLA-EMail' is expected to be false because resources.SLA-Platform=BASIC but is true"); + assertThat(result).containsExactly("'D-12345:Test-Project:null.resources.SLA-EMail' is expected to be false because SLA-Platform=BASIC but is true"); } @Test void containsAllValidations() { // when - final var validator = forType(MANAGED_SERVER); + final var validator = HsBookingItemEntityValidatorRegistry.forType(MANAGED_SERVER); // then assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( - "{type=integer, propertyName=CPUs, required=true, unit=null, min=1, max=32, step=null}", - "{type=integer, propertyName=RAM, required=true, unit=GB, min=1, max=128, step=null}", - "{type=integer, propertyName=SSD, required=true, unit=GB, min=25, max=1000, step=25}", - "{type=integer, propertyName=HDD, required=false, unit=GB, min=0, max=4000, step=250}", - "{type=integer, propertyName=Traffic, required=true, unit=GB, min=250, max=10000, step=250}", - "{type=enumeration, propertyName=SLA-Platform, required=false, values=[BASIC, EXT8H, EXT4H, EXT2H]}", - "{type=boolean, propertyName=SLA-EMail, required=false, falseIf={SLA-Platform=BASIC}}", - "{type=boolean, propertyName=SLA-Maria, required=false, falseIf={SLA-Platform=BASIC}}", - "{type=boolean, propertyName=SLA-PgSQL, required=false, falseIf={SLA-Platform=BASIC}}", - "{type=boolean, propertyName=SLA-Office, required=false, falseIf={SLA-Platform=BASIC}}", - "{type=boolean, propertyName=SLA-Web, required=false, falseIf={SLA-Platform=BASIC}}"); + "{type=integer, propertyName=CPUs, min=1, max=32, required=true}", + "{type=integer, propertyName=RAM, unit=GB, min=1, max=128, required=true}", + "{type=integer, propertyName=SSD, unit=GB, min=25, max=1000, step=25, required=true, isTotalsValidator=true, thresholdPercentage=200}", + "{type=integer, propertyName=HDD, unit=GB, min=0, max=4000, step=250, defaultValue=0, isTotalsValidator=true, thresholdPercentage=200}", + "{type=integer, propertyName=Traffic, unit=GB, min=250, max=10000, step=250, required=true, isTotalsValidator=true, thresholdPercentage=200}", + "{type=enumeration, propertyName=SLA-Platform, values=[BASIC, EXT8H, EXT4H, EXT2H], defaultValue=BASIC}", + "{type=boolean, propertyName=SLA-EMail}", // TODO.impl: falseIf-validation is missing in output + "{type=boolean, propertyName=SLA-Maria}", + "{type=boolean, propertyName=SLA-PgSQL}", + "{type=boolean, propertyName=SLA-Office}", + "{type=boolean, propertyName=SLA-Web}"); } + + @Test + void validatesExceedingPropertyTotals() { + // given + final var subCloudServerBookingItemEntity = HsBookingItemEntity.builder() + .type(CLOUD_SERVER) + .resources(ofEntries( + entry("CPUs", 2), + entry("RAM", 10), + entry("SSD", 50), + entry("Traffic", 2500) + )) + .build(); + final HsBookingItemEntity subManagedServerBookingItemEntity = HsBookingItemEntity.builder() + .type(MANAGED_SERVER) + .resources(ofEntries( + entry("CPUs", 3), + entry("RAM", 20), + entry("SSD", 100), + entry("Traffic", 3000) + )) + .build(); + final var privateCloudBookingItemEntity = HsBookingItemEntity.builder() + .type(PRIVATE_CLOUD) + .project(project) + .resources(ofEntries( + entry("CPUs", 4), + entry("RAM", 20), + entry("SSD", 100), + entry("Traffic", 5000) + )) + .subBookingItems(of( + subManagedServerBookingItemEntity, + subCloudServerBookingItemEntity + )) + .build(); + + subManagedServerBookingItemEntity.setParentItem(privateCloudBookingItemEntity); + subCloudServerBookingItemEntity.setParentItem(privateCloudBookingItemEntity); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(subManagedServerBookingItemEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'D-12345:Test-Project:null.resources.CPUs' maximum total is 4, but actual total CPUs is 5", + "'D-12345:Test-Project:null.resources.RAM' maximum total is 20 GB, but actual total RAM is 30 GB", + "'D-12345:Test-Project:null.resources.SSD' maximum total is 100 GB, but actual total SSD is 150 GB", + "'D-12345:Test-Project:null.resources.Traffic' maximum total is 5000 GB, but actual total Traffic is 5500 GB" + ); + } + + @Test + void validatesExceedingTotals() { + // given + final var managedWebspaceBookingItem = HsBookingItemEntity.builder() + .type(MANAGED_WEBSPACE) + .project(project) + .caption("test Managed-Webspace") + .resources(ofEntries( + entry("SSD", 100), + entry("Traffic", 1000), + entry("Multi", 1) + )) + .relatedHostingAsset(HsHostingAssetEntity.builder() + .type(HsHostingAssetType.MANAGED_WEBSPACE) + .identifier("abc00") + .subHostingAssets(concat( + generate(26, HsHostingAssetType.UNIX_USER, "xyz00-%c%c"), + generateDbUsersWithDatabases(3, HsHostingAssetType.PGSQL_USER, + "xyz00_%c%c", + 1, HsHostingAssetType.PGSQL_DATABASE + ), + generateDbUsersWithDatabases(3, HsHostingAssetType.MARIADB_USER, + "xyz00_%c%c", + 2, HsHostingAssetType.MARIADB_DATABASE + ), + generateDomainEmailSetupsWithEMailAddresses(26, HsHostingAssetType.DOMAIN_MBOX_SETUP, + "%c%c.example.com", + 10, HsHostingAssetType.EMAIL_ADDRESS + ) + )) + .build() + ) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(managedWebspaceBookingItem); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'D-12345:Test-Project:test Managed-Webspace.resources.Multi=1 allows at maximum 25 unix users, but 26 found", + "'D-12345:Test-Project:test Managed-Webspace.resources.Multi=1 allows at maximum 5 database users, but 6 found", + "'D-12345:Test-Project:test Managed-Webspace.resources.Multi=1 allows at maximum 5 databases, but 9 found", + "'D-12345:Test-Project:test Managed-Webspace.resources.Multi=1 allows at maximum 250 databases, but 260 found" + ); + } + + @SafeVarargs + private List concat(final List... hostingAssets) { + return stream(hostingAssets) + .flatMap(Collection::stream) + .collect(Collectors.toList()); + } + + private List generate(final int count, final HsHostingAssetType hostingAssetType, + final String identifierPattern) { + return IntStream.range(0, count) + .mapToObj(number -> HsHostingAssetEntity.builder() + .type(hostingAssetType) + .identifier(identifierPattern.formatted((number/'a')+'a', (number%'a')+'a')) + .build()) + .toList(); + } + + private List generateDbUsersWithDatabases( + final int userCount, + final HsHostingAssetType directAssetType, + final String directAssetIdentifierFormat, + final int dbCount, + final HsHostingAssetType subAssetType) { + return IntStream.range(0, userCount) + .mapToObj(n -> HsHostingAssetEntity.builder() + .type(directAssetType) + .identifier(directAssetIdentifierFormat.formatted((n/'a')+'a', (n%'a')+'a')) + .subHostingAssets( + generate(dbCount, subAssetType, "%c%c.example.com".formatted((n/'a')+'a', (n%'a')+'a')) + ) + .build()) + .toList(); + } + + private List generateDomainEmailSetupsWithEMailAddresses( + final int domainCount, + final HsHostingAssetType directAssetType, + final String directAssetIdentifierFormat, + final int emailAddressCount, + final HsHostingAssetType subAssetType) { + return IntStream.range(0, domainCount) + .mapToObj(n -> HsHostingAssetEntity.builder() + .type(directAssetType) + .identifier(directAssetIdentifierFormat.formatted((n/'a')+'a', (n%'a')+'a')) + .subHostingAssets( + generate(emailAddressCount, subAssetType, "xyz00_%c%c%%c%%c".formatted((n/'a')+'a', (n%'a')+'a')) + ) + .build()) + .toList(); + } + } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidatorUnitTest.java index 8a278850..e75cd551 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsManagedWebspaceBookingItemValidatorUnitTest.java @@ -1,54 +1,66 @@ package net.hostsharing.hsadminng.hs.booking.item.validators; +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectEntity; import org.junit.jupiter.api.Test; import java.util.Map; import static java.util.Map.entry; import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_WEBSPACE; -import static net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidators.forType; import static org.assertj.core.api.Assertions.assertThat; class HsManagedWebspaceBookingItemValidatorUnitTest { + final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder() + .debitorNumber(12345) + .build(); + final HsBookingProjectEntity project = HsBookingProjectEntity.builder() + .debitor(debitor) + .caption("Test-Project") + .build(); + @Test void validatesProperties() { // given final var mangedServerBookingItemEntity = HsBookingItemEntity.builder() .type(MANAGED_WEBSPACE) + .project(project) + .caption("Test Managed-Webspace") .resources(Map.ofEntries( entry("CPUs", 2), entry("RAM", 25), - entry("SSD", 25), entry("Traffic", 250), entry("SLA-EMail", true) )) .build(); - final var validator = forType(mangedServerBookingItemEntity.getType()); // when - final var result = validator.validate(mangedServerBookingItemEntity); + final var result = HsBookingItemEntityValidatorRegistry.doValidate(mangedServerBookingItemEntity); // then assertThat(result).containsExactlyInAnyOrder( - "'resources.CPUs' is not expected but is set to '2'", - "'resources.SLA-EMail' is not expected but is set to 'true'", - "'resources.RAM' is not expected but is set to '25'"); + "'D-12345:Test-Project:Test Managed-Webspace.resources.CPUs' is not expected but is set to '2'", + "'D-12345:Test-Project:Test Managed-Webspace.resources.RAM' is not expected but is set to '25'", + "'D-12345:Test-Project:Test Managed-Webspace.resources.SSD' is required but missing", + "'D-12345:Test-Project:Test Managed-Webspace.resources.SLA-EMail' is not expected but is set to 'true'" + ); } @Test void containsAllValidations() { // when - final var validator = forType(MANAGED_WEBSPACE); + final var validator = HsBookingItemEntityValidatorRegistry.forType(MANAGED_WEBSPACE); // then assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( - "{type=integer, propertyName=SSD, required=true, unit=GB, min=1, max=100, step=1}", - "{type=integer, propertyName=HDD, required=false, unit=GB, min=0, max=250, step=10}", - "{type=integer, propertyName=Traffic, required=true, unit=GB, min=10, max=1000, step=10}", - "{type=enumeration, propertyName=SLA-Platform, required=false, values=[BASIC, EXT24H]}", - "{type=integer, propertyName=Daemons, required=false, unit=null, min=0, max=10, step=null}", - "{type=boolean, propertyName=Online Office Server, required=false, falseIf=null}"); + "{type=integer, propertyName=SSD, unit=GB, min=1, max=100, step=1, required=true}", + "{type=integer, propertyName=HDD, unit=GB, min=0, max=250, step=10}", + "{type=integer, propertyName=Traffic, unit=GB, min=10, max=1000, step=10, required=true}", + "{type=integer, propertyName=Multi, min=1, max=100, step=1, defaultValue=1}", + "{type=integer, propertyName=Daemons, min=0, max=10, defaultValue=0}", + "{type=boolean, propertyName=Online Office Server}", + "{type=enumeration, propertyName=SLA-Platform, values=[BASIC, EXT24H], defaultValue=BASIC}"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsPrivateCloudBookingItemValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsPrivateCloudBookingItemValidatorUnitTest.java new file mode 100644 index 00000000..2a100d2c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsPrivateCloudBookingItemValidatorUnitTest.java @@ -0,0 +1,139 @@ +package net.hostsharing.hsadminng.hs.booking.item.validators; + +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectEntity; +import org.junit.jupiter.api.Test; + +import static java.util.List.of; +import static java.util.Map.entry; +import static java.util.Map.ofEntries; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.PRIVATE_CLOUD; +import static org.assertj.core.api.Assertions.assertThat; + +class HsPrivateCloudBookingItemValidatorUnitTest { + + final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder() + .debitorNumber(12345) + .build(); + final HsBookingProjectEntity project = HsBookingProjectEntity.builder() + .debitor(debitor) + .caption("Test-Project") + .build(); + + @Test + void validatesPropertyTotals() { + // given + final var privateCloudBookingItemEntity = HsBookingItemEntity.builder() + .type(PRIVATE_CLOUD) + .caption("myPC") + .resources(ofEntries( + entry("CPUs", 4), + entry("RAM", 20), + entry("SSD", 100), + entry("Traffic", 5000), + entry("SLA-Platform EXT4H", 2), + entry("SLA-EMail", 2) + )) + .subBookingItems(of( + HsBookingItemEntity.builder() + .type(MANAGED_SERVER) + .caption("myMS-1") + .resources(ofEntries( + entry("CPUs", 2), + entry("RAM", 10), + entry("SSD", 50), + entry("Traffic", 2500), + entry("SLA-Platform", "EXT4H"), + entry("SLA-EMail", true) + )) + .build(), + HsBookingItemEntity.builder() + .type(CLOUD_SERVER) + .caption("myMS-2") + .resources(ofEntries( + entry("CPUs", 2), + entry("RAM", 10), + entry("SSD", 50), + entry("Traffic", 2500), + entry("SLA-Platform", "EXT4H"), + entry("SLA-EMail", true) + )) + .build() + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(privateCloudBookingItemEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void validatesExceedingPropertyTotals() { + // given + final var privateCloudBookingItemEntity = HsBookingItemEntity.builder() + .project(project) + .type(PRIVATE_CLOUD) + .caption("myPC") + .resources(ofEntries( + entry("CPUs", 4), + entry("RAM", 20), + entry("SSD", 100), + entry("Traffic", 5000), + entry("SLA-Platform EXT2H", 1), + entry("SLA-EMail", 1) + )) + .subBookingItems(of( + HsBookingItemEntity.builder() + .type(MANAGED_SERVER) + .caption("myMS-1") + .resources(ofEntries( + entry("CPUs", 3), + entry("RAM", 20), + entry("SSD", 100), + entry("Traffic", 3000), + entry("SLA-Platform", "EXT2H"), + entry("SLA-EMail", true) + )) + .build(), + HsBookingItemEntity.builder() + .type(CLOUD_SERVER) + .caption("myMS-2") + .resources(ofEntries( + entry("CPUs", 2), + entry("RAM", 10), + entry("SSD", 50), + entry("Traffic", 2500), + entry("SLA-Platform", "EXT2H"), + entry("SLA-EMail", true), + entry("SLA-Maria", true), + entry("SLA-PgSQL", true), + entry("SLA-Office", true), + entry("SLA-Web", true) + )) + .build() + )) + .build(); + + // when + final var result = HsBookingItemEntityValidatorRegistry.doValidate(privateCloudBookingItemEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'D-12345:Test-Project:myPC.resources.CPUs' maximum total is 4, but actual total CPUs is 5", + "'D-12345:Test-Project:myPC.resources.RAM' maximum total is 20 GB, but actual total RAM is 30 GB", + "'D-12345:Test-Project:myPC.resources.SSD' maximum total is 100 GB, but actual total SSD is 150 GB", + "'D-12345:Test-Project:myPC.resources.Traffic' maximum total is 5000 GB, but actual total Traffic is 5500 GB", + "'D-12345:Test-Project:myPC.resources.SLA-Platform EXT2H maximum total is 1, but actual total for SLA-Platform=EXT2H is 2", + "'D-12345:Test-Project:myPC.resources.SLA-EMail' maximum total is 1, but actual total SLA-EMail is 2", + "'D-12345:Test-Project:myPC.resources.SLA-Maria' maximum total is 0, but actual total SLA-Maria is 1", + "'D-12345:Test-Project:myPC.resources.SLA-PgSQL' maximum total is 0, but actual total SLA-PgSQL is 1", + "'D-12345:Test-Project:myPC.resources.SLA-Office' maximum total is 0, but actual total SLA-Office is 1", + "'D-12345:Test-Project:myPC.resources.SLA-Web' maximum total is 0, but actual total SLA-Web is 1" + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectControllerAcceptanceTest.java new file mode 100644 index 00000000..9a4c2391 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectControllerAcceptanceTest.java @@ -0,0 +1,280 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorRepository; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.UUID; + +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.matchesRegex; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = { HsadminNgApplication.class, JpaAttempt.class } +) +@Transactional +class HsBookingProjectControllerAcceptanceTest extends ContextBasedTestWithCleanup { + + @LocalServerPort + private Integer port; + + @Autowired + HsBookingProjectRepository bookingProjectRepo; + + @Autowired + HsBookingProjectRepository projectRepo; + + @Autowired + HsBookingDebitorRepository debitorRepo; + + @Autowired + JpaAttempt jpaAttempt; + + @PersistenceContext + EntityManager em; + + @Nested + class ListBookingProjects { + + @Test + void globalAdmin_canViewAllBookingProjectsOfArbitraryDebitor() { + + // given + context("superuser-alex@hostsharing.net"); + final var givenDebitor = debitorRepo.findByDebitorNumber(1000111).stream() + .findFirst() + .orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/booking/projects?debitorUuid=" + givenDebitor.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "caption": "D-1000111 default project" + } + ] + """)); + // @formatter:on + } + } + + @Nested + class AddBookingProject { + + @Test + void globalAdmin_canAddBookingProject() { + + context.define("superuser-alex@hostsharing.net"); + final var givenDebitor = debitorRepo.findByDebitorNumber(1000111).stream() + .findFirst() + .orElseThrow(); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "debitorUuid": "%s", + "caption": "some new project" + } + """.formatted(givenDebitor.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/booking/projects") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "caption": "some new project" + } + """)) + .header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/booking/projects/[^/]*")) + .extract().header("Location"); // @formatter:on + + // finally, the new bookingProject can be accessed under the generated UUID + final var newUserUuid = UUID.fromString( + location.substring(location.lastIndexOf('/') + 1)); + assertThat(newUserUuid).isNotNull(); + } + } + + @Nested + class GetBookingProject { + + @Test + void globalAdmin_canGetArbitraryBookingProject() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingProjectUuid = bookingProjectRepo.findByCaption("D-1000111 default project").stream() + .findAny().orElseThrow().getUuid(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .get("http://localhost/api/hs/booking/projects/" + givenBookingProjectUuid) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "caption": "D-1000111 default project" + } + """)); // @formatter:on + } + + @Test + void normalUser_canNotGetUnrelatedBookingProject() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingProjectUuid = bookingProjectRepo.findByCaption("D-1000212 default project").stream() + .map(HsBookingProjectEntity::getUuid) + .findAny().orElseThrow(); + + RestAssured // @formatter:off + .given() + .header("current-user", "selfregistered-user-drew@hostsharing.org") + .port(port) + .when() + .get("http://localhost/api/hs/booking/projects/" + givenBookingProjectUuid) + .then().log().body().assertThat() + .statusCode(404); // @formatter:on + } + + @Test + void debitorAgentUser_canGetRelatedBookingProject() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingProjectUuid = bookingProjectRepo.findByCaption("D-1000313 default project").stream() + .findAny().orElseThrow().getUuid(); + + RestAssured // @formatter:off + .given() + .header("current-user", "person-TuckerJack@example.com") + .port(port) + .when() + .get("http://localhost/api/hs/booking/projects/" + givenBookingProjectUuid) + .then().log().all().assertThat() + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + { + "caption": "D-1000313 default project" + } + """)); // @formatter:on + } + } + + @Nested + class PatchBookingProject { + + @Test + void globalAdmin_canPatchAllUpdatablePropertiesOfBookingProject() { + + final var givenBookingProject = givenSomeBookingProject(1000111, "some project"); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "caption": "some project" + } + """) + .port(port) + .when() + .patch("http://localhost/api/hs/booking/projects/" + givenBookingProject.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "caption": "some project" + } + """)); // @formatter:on + + // finally, the bookingProject is actually updated + context.define("superuser-alex@hostsharing.net"); + assertThat(bookingProjectRepo.findByUuid(givenBookingProject.getUuid())).isPresent().get() + .matches(mandate -> { + assertThat(mandate.getDebitor().toString()).isEqualTo("booking-debitor(D-1000111: fir)"); + return true; + }); + } + } + + @Nested + class DeleteBookingProject { + + @Test + void globalAdmin_canDeleteArbitraryBookingProject() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingProject = givenSomeBookingProject(1000111, "some project"); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .port(port) + .when() + .delete("http://localhost/api/hs/booking/projects/" + givenBookingProject.getUuid()) + .then().log().body().assertThat() + .statusCode(204); // @formatter:on + + // then the given bookingProject is gone + assertThat(bookingProjectRepo.findByUuid(givenBookingProject.getUuid())).isEmpty(); + } + + @Test + void normalUser_canNotDeleteUnrelatedBookingProject() { + context.define("superuser-alex@hostsharing.net"); + final var givenBookingProject = givenSomeBookingProject(1000111, "some project"); + + RestAssured // @formatter:off + .given() + .header("current-user", "selfregistered-user-drew@hostsharing.org") + .port(port) + .when() + .delete("http://localhost/api/hs/booking/projects/" + givenBookingProject.getUuid()) + .then().log().body().assertThat() + .statusCode(404); // @formatter:on + + // then the given bookingProject is still there + assertThat(bookingProjectRepo.findByUuid(givenBookingProject.getUuid())).isNotEmpty(); + } + } + + private HsBookingProjectEntity givenSomeBookingProject(final int debitorNumber, final String caption) { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + final var givenDebitor = debitorRepo.findByDebitorNumber(debitorNumber).stream().findAny().orElseThrow(); + final var newBookingProject = HsBookingProjectEntity.builder() + .uuid(UUID.randomUUID()) + .debitor(givenDebitor) + .caption(caption) + .build(); + + return bookingProjectRepo.save(newBookingProject); + }).assertSuccessful().returnedValue(); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityPatcherUnitTest.java new file mode 100644 index 00000000..37229d26 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityPatcherUnitTest.java @@ -0,0 +1,74 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingProjectPatchResource; +import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; +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.UUID; +import java.util.stream.Stream; + +import static net.hostsharing.hsadminng.hs.booking.debitor.TestHsBookingDebitor.TEST_BOOKING_DEBITOR; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; + +@TestInstance(PER_CLASS) +@ExtendWith(MockitoExtension.class) +class HsBookingProjectEntityPatcherUnitTest extends PatchUnitTestBase< + HsBookingProjectPatchResource, + HsBookingProjectEntity + > { + + private static final UUID INITIAL_BOOKING_PROJECT_UUID = UUID.randomUUID(); + + private static final String INITIAL_CAPTION = "initial caption"; + private static final String PATCHED_CAPTION = "patched caption"; + + @Mock + private EntityManager em; + + @BeforeEach + void initMocks() { + lenient().when(em.getReference(eq(HsOfficeDebitorEntity.class), any())).thenAnswer(invocation -> + HsOfficeDebitorEntity.builder().uuid(invocation.getArgument(1)).build()); + lenient().when(em.getReference(eq(HsBookingProjectEntity.class), any())).thenAnswer(invocation -> + HsBookingProjectEntity.builder().uuid(invocation.getArgument(1)).build()); + } + + @Override + protected HsBookingProjectEntity newInitialEntity() { + final var entity = new HsBookingProjectEntity(); + entity.setUuid(INITIAL_BOOKING_PROJECT_UUID); + entity.setDebitor(TEST_BOOKING_DEBITOR); + entity.setCaption(INITIAL_CAPTION); + return entity; + } + + @Override + protected HsBookingProjectPatchResource newPatchResource() { + return new HsBookingProjectPatchResource(); + } + + @Override + protected HsBookingProjectEntityPatcher createPatcher(final HsBookingProjectEntity bookingProject) { + return new HsBookingProjectEntityPatcher(bookingProject); + } + + @Override + protected Stream propertyTestDescriptors() { + return Stream.of( + new JsonNullableProperty<>( + "caption", + HsBookingProjectPatchResource::setCaption, + PATCHED_CAPTION, + HsBookingProjectEntity::setCaption) + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityUnitTest.java new file mode 100644 index 00000000..1d53070b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectEntityUnitTest.java @@ -0,0 +1,27 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import org.junit.jupiter.api.Test; + +import static net.hostsharing.hsadminng.hs.booking.debitor.TestHsBookingDebitor.TEST_BOOKING_DEBITOR; +import static org.assertj.core.api.Assertions.assertThat; + +class HsBookingProjectEntityUnitTest { + final HsBookingProjectEntity givenBookingProject = HsBookingProjectEntity.builder() + .debitor(TEST_BOOKING_DEBITOR) + .caption("some caption") + .build(); + + @Test + void toStringContainsAllPropertiesAndResourcesSortedByKey() { + final var result = givenBookingProject.toString(); + + assertThat(result).isEqualTo("HsBookingProjectEntity(D-1234500, some caption)"); + } + + @Test + void toShortStringContainsOnlyMemberNumberAndCaption() { + final var result = givenBookingProject.toShortString(); + + assertThat(result).isEqualTo("D-1234500:some caption"); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepositoryIntegrationTest.java new file mode 100644 index 00000000..e73bf942 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/HsBookingProjectRepositoryIntegrationTest.java @@ -0,0 +1,326 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorRepository; +import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; +import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.Nested; +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.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.orm.jpa.JpaSystemException; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.List; + +import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; +import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; +import static net.hostsharing.hsadminng.mapper.Array.fromFormatted; +import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import({ Context.class, JpaAttempt.class }) +class HsBookingProjectRepositoryIntegrationTest extends ContextBasedTestWithCleanup { + + @Autowired + HsBookingProjectRepository bookingProjectRepo; + + @Autowired + HsBookingProjectRepository projectRepo; + + @Autowired + HsBookingDebitorRepository debitorRepo; + + @Autowired + RawRbacRoleRepository rawRoleRepo; + + @Autowired + RawRbacGrantRepository rawGrantRepo; + + @Autowired + JpaAttempt jpaAttempt; + + @PersistenceContext + EntityManager em; + + @MockBean + HttpServletRequest request; + + @Nested + class CreateBookingProject { + + @Test + public void testHostsharingAdmin_withoutAssumedRole_canCreateNewBookingProject() { + // given + context("superuser-alex@hostsharing.net"); + final var count = bookingProjectRepo.count(); + final var givenDebitor = debitorRepo.findByDebitorNumber(1000111).get(0); + + // when + final var result = attempt(em, () -> { + final var newBookingProject = HsBookingProjectEntity.builder() + .debitor(givenDebitor) + .caption("some new booking project") + .build(); + return toCleanup(bookingProjectRepo.save(newBookingProject)); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isNotNull().extracting(HsBookingProjectEntity::getUuid).isNotNull(); + assertThatBookingProjectIsPersisted(result.returnedValue()); + assertThat(bookingProjectRepo.count()).isEqualTo(count + 1); + } + + @Test + public void createsAndGrantsRoles() { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()).stream() + .map(s -> s.replace("hs_office_", "")) + .toList(); + + // when + attempt(em, () -> { + final var givenDebitor = debitorRepo.findByDebitorNumber(1000111).get(0); + final var newBookingProject = HsBookingProjectEntity.builder() + .debitor(givenDebitor) + .caption("some new booking project") + .build(); + return toCleanup(bookingProjectRepo.save(newBookingProject)); + }); + + // then + final var all = rawRoleRepo.findAll(); + assertThat(distinctRoleNamesOf(all)).containsExactlyInAnyOrder(Array.from( + initialRoleNames, + "hs_booking_project#D-1000111-somenewbookingproject:ADMIN", + "hs_booking_project#D-1000111-somenewbookingproject:AGENT", + "hs_booking_project#D-1000111-somenewbookingproject:OWNER", + "hs_booking_project#D-1000111-somenewbookingproject:TENANT")); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())) + .map(s -> s.replace("hs_office_", "")) + .containsExactlyInAnyOrder(fromFormatted( + initialGrantNames, + + // global-admin + "{ grant perm:hs_booking_project#D-1000111-somenewbookingproject:DELETE to role:global#global:ADMIN by system and assume }", + + // owner + "{ grant role:hs_booking_project#D-1000111-somenewbookingproject:ADMIN to role:hs_booking_project#D-1000111-somenewbookingproject:OWNER by system and assume }", + + // admin + "{ grant role:hs_booking_project#D-1000111-somenewbookingproject:AGENT to role:hs_booking_project#D-1000111-somenewbookingproject:ADMIN by system and assume }", + "{ grant perm:hs_booking_project#D-1000111-somenewbookingproject:UPDATE to role:hs_booking_project#D-1000111-somenewbookingproject:ADMIN by system and assume }", + "{ grant perm:hs_booking_project#D-1000111-somenewbookingproject:INSERT>hs_booking_item to role:hs_booking_project#D-1000111-somenewbookingproject:ADMIN by system and assume }", + + // agent + "{ grant role:hs_booking_project#D-1000111-somenewbookingproject:OWNER to role:relation#FirstGmbH-with-DEBITOR-FirstGmbH:AGENT by system and assume }", + "{ grant role:hs_booking_project#D-1000111-somenewbookingproject:TENANT to role:hs_booking_project#D-1000111-somenewbookingproject:AGENT by system and assume }", + + // tenant + "{ grant role:relation#FirstGmbH-with-DEBITOR-FirstGmbH:TENANT to role:hs_booking_project#D-1000111-somenewbookingproject:TENANT by system and assume }", + "{ grant perm:hs_booking_project#D-1000111-somenewbookingproject:SELECT to role:hs_booking_project#D-1000111-somenewbookingproject:TENANT by system and assume }", + + null)); + } + + private void assertThatBookingProjectIsPersisted(final HsBookingProjectEntity saved) { + final var found = bookingProjectRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().map(HsBookingProjectEntity::toString).get().isEqualTo(saved.toString()); + } + } + + @Nested + class FindByDebitorUuid { + + @Test + public void globalAdmin_withoutAssumedRole_canViewAllBookingProjectsOfArbitraryDebitor() { + // given + context("superuser-alex@hostsharing.net"); + final var debitorUuid = debitorRepo.findByDebitorNumber(1000212).stream() + .findAny().orElseThrow().getUuid(); + + // when + final var result = bookingProjectRepo.findAllByDebitorUuid(debitorUuid); + + // then + allTheseBookingProjectsAreReturned( + result, + "HsBookingProjectEntity(D-1000212, D-1000212 default project)"); + } + + @Test + public void normalUser_canViewOnlyRelatedBookingProjects() { + // given: + context("person-FirbySusan@example.com"); + final var debitorUuid = debitorRepo.findByDebitorNumber(1000111).stream() + .findAny().orElseThrow().getUuid(); + + // when: + final var result = bookingProjectRepo.findAllByDebitorUuid(debitorUuid); + + // then: + exactlyTheseBookingProjectsAreReturned( + result, + "HsBookingProjectEntity(D-1000111, D-1000111 default project)"); + } + } + + @Nested + class UpdateBookingProject { + + @Test + public void hostsharingAdmin_canUpdateArbitraryBookingProject() { + // given + final var givenBookingProjectUuid = givenSomeTemporaryBookingProject(1000111).getUuid(); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + final var foundBookingProject = em.find(HsBookingProjectEntity.class, givenBookingProjectUuid); + return toCleanup(bookingProjectRepo.save(foundBookingProject)); + }); + + // then + result.assertSuccessful(); + jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + assertThatBookingProjectActuallyInDatabase(result.returnedValue()); + }).assertSuccessful(); + } + + private void assertThatBookingProjectActuallyInDatabase(final HsBookingProjectEntity saved) { + final var found = bookingProjectRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().get().isNotSameAs(saved) + .extracting(Object::toString).isEqualTo(saved.toString()); + } + } + + @Nested + class DeleteByUuid { + + @Test + public void globalAdmin_withoutAssumedRole_canDeleteAnyBookingProject() { + // given + context("superuser-alex@hostsharing.net", null); + final var givenBookingProject = givenSomeTemporaryBookingProject(1000111); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + bookingProjectRepo.deleteByUuid(givenBookingProject.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-fran@hostsharing.net", null); + return bookingProjectRepo.findByUuid(givenBookingProject.getUuid()); + }).assertSuccessful().returnedValue()).isEmpty(); + } + + @Test + public void nonGlobalAdmin_canNotDeleteTheirRelatedBookingProject() { + // given + context("superuser-alex@hostsharing.net", null); + final var givenBookingProject = givenSomeTemporaryBookingProject(1000111); + + // when + final var result = jpaAttempt.transacted(() -> { + context("person-FirbySusan@example.com"); + assertThat(bookingProjectRepo.findByUuid(givenBookingProject.getUuid())).isPresent(); + + bookingProjectRepo.deleteByUuid(givenBookingProject.getUuid()); + }); + + // then + result.assertExceptionWithRootCauseMessage( + JpaSystemException.class, + "[403] Subject ", " is not allowed to delete hs_booking_project"); + assertThat(jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return bookingProjectRepo.findByUuid(givenBookingProject.getUuid()); + }).assertSuccessful().returnedValue()).isPresent(); // still there + } + + @Test + public void deletingABookingProjectAlsoDeletesRelatedRolesAndGrants() { + // given + context("superuser-alex@hostsharing.net"); + final var initialRoleNames = Array.from(distinctRoleNamesOf(rawRoleRepo.findAll())); + final var initialGrantNames = Array.from(distinctGrantDisplaysOf(rawGrantRepo.findAll())); + final var givenBookingProject = givenSomeTemporaryBookingProject(1000111); + + // when + final var result = jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + return bookingProjectRepo.deleteByUuid(givenBookingProject.getUuid()); + }); + + // then + result.assertSuccessful(); + assertThat(result.returnedValue()).isEqualTo(1); + assertThat(distinctRoleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames); + assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames); + } + } + + @Test + public void auditJournalLogIsAvailable() { + // given + final var query = em.createNativeQuery(""" + select currentTask, targetTable, targetOp + from tx_journal_v + where targettable = 'hs_booking_project'; + """); + + // when + @SuppressWarnings("unchecked") final List customerLogEntries = query.getResultList(); + + // then + assertThat(customerLogEntries).map(Arrays::toString).contains( + "[creating booking-project test-data 1000111, hs_booking_project, INSERT]", + "[creating booking-project test-data 1000212, hs_booking_project, INSERT]", + "[creating booking-project test-data 1000313, hs_booking_project, INSERT]"); + } + + private HsBookingProjectEntity givenSomeTemporaryBookingProject(final int debitorNumber) { + return jpaAttempt.transacted(() -> { + context("superuser-alex@hostsharing.net"); + final var givenDebitor = debitorRepo.findByDebitorNumber(debitorNumber).get(0); + final var newBookingProject = HsBookingProjectEntity.builder() + .debitor(givenDebitor) + .caption("some temp project") + .build(); + + return toCleanup(bookingProjectRepo.save(newBookingProject)); + }).assertSuccessful().returnedValue(); + } + + void exactlyTheseBookingProjectsAreReturned( + final List actualResult, + final String... bookingProjectNames) { + assertThat(actualResult) + .extracting(HsBookingProjectEntity::toString) + .containsExactlyInAnyOrder(bookingProjectNames); + } + + void allTheseBookingProjectsAreReturned( + final List actualResult, + final String... bookingProjectNames) { + assertThat(actualResult) + .extracting(HsBookingProjectEntity::toString) + .contains(bookingProjectNames); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/project/TestHsBookingProject.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/TestHsBookingProject.java new file mode 100644 index 00000000..6190c36b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/project/TestHsBookingProject.java @@ -0,0 +1,15 @@ +package net.hostsharing.hsadminng.hs.booking.project; + +import lombok.experimental.UtilityClass; + +import static net.hostsharing.hsadminng.hs.booking.debitor.TestHsBookingDebitor.TEST_BOOKING_DEBITOR; + +@UtilityClass +public class TestHsBookingProject { + + + public static final HsBookingProjectEntity TEST_PROJECT = HsBookingProjectEntity.builder() + .debitor(TEST_BOOKING_DEBITOR) + .caption("test project") + .build(); +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java index f3eb66ee..e45a157b 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java @@ -3,33 +3,47 @@ package net.hostsharing.hsadminng.hs.hosting.asset; import io.restassured.RestAssured; import io.restassured.http.ContentType; import net.hostsharing.hsadminng.HsadminNgApplication; +import net.hostsharing.hsadminng.hash.LinuxEtcShadowHashGenerator; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRepository; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRepository; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository; import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; +import org.junit.jupiter.api.ClassOrderer; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.transaction.annotation.Transactional; +import java.util.HashMap; import java.util.Map; import java.util.UUID; +import java.util.function.Supplier; import static java.util.Map.entry; -import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.EMAIL_ALIAS; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER; import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.strictlyEquals; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.matchesRegex; +@Transactional @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = { HsadminNgApplication.class, JpaAttempt.class } ) -@Transactional +@TestClassOrder(ClassOrderer.OrderAnnotation.class) // fail early on fetching problems class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup { @LocalServerPort @@ -41,13 +55,20 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup @Autowired HsBookingItemRepository bookingItemRepo; + @Autowired + HsBookingProjectRepository projectRepo; + @Autowired HsOfficeDebitorRepository debitorRepo; + @Autowired + HsOfficeContactRepository contactRepo; + @Autowired JpaAttempt jpaAttempt; @Nested + @Order(2) class ListAssets { @Test @@ -55,14 +76,15 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup // given context("superuser-alex@hostsharing.net"); - final var givenDebitor = debitorRepo.findDebitorByDebitorNumber(1000111).get(0); + final var givenProject = projectRepo.findByCaption("D-1000111 default project").stream() + .findAny().orElseThrow(); RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") .port(port) .when() - .get("http://localhost/api/hs/hosting/assets?debitorUuid=" + givenDebitor.getUuid()) + .get("http://localhost/api/hs/hosting/assets?projectUuid=" + givenProject.getUuid() + "&type=MANAGED_WEBSPACE") .then().log().all().assertThat() .statusCode(200) .contentType("application/json") @@ -70,34 +92,9 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup [ { "type": "MANAGED_WEBSPACE", - "identifier": "aaa01", - "caption": "some Webspace", - "config": { - "HDD": 2048, - "RAM": 1, - "SDD": 512, - "extra": 42 - } - }, - { - "type": "MANAGED_SERVER", - "identifier": "vm1011", - "caption": "some ManagedServer", - "config": { - "CPU": 2, - "SDD": 512, - "extra": 42 - } - }, - { - "type": "CLOUD_SERVER", - "identifier": "vm2011", - "caption": "another CloudServer", - "config": { - "CPU": 2, - "HDD": 1024, - "extra": 42 - } + "identifier": "fir01", + "caption": "some Webspace", + "config": {} } ] """)); @@ -105,66 +102,57 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup } @Test - void globalAdmin_canViewAllAssetsByType() { + void webspaceAgent_canViewAllAssetsByType() { // given context("superuser-alex@hostsharing.net"); RestAssured // @formatter:off .given() - .header("current-user", "superuser-alex@hostsharing.net") - .port(port) + .header("current-user", "superuser-alex@hostsharing.net") + .header("assumed-roles", "hs_hosting_asset#fir01:AGENT") + .port(port) .when() - .get("http://localhost/api/hs/hosting/assets?type=" + MANAGED_SERVER) + . get("http://localhost/api/hs/hosting/assets?type=" + EMAIL_ALIAS) .then().log().all().assertThat() - .statusCode(200) - .contentType("application/json") - .body("", lenientlyEquals(""" - [ - { - "type": "MANAGED_SERVER", - "identifier": "vm1011", - "caption": "some ManagedServer", - "config": { - "CPU": 2, - "SDD": 512, - "extra": 42 + .statusCode(200) + .contentType("application/json") + .body("", lenientlyEquals(""" + [ + { + "type": "EMAIL_ALIAS", + "identifier": "fir01-web", + "caption": "some E-Mail-Alias", + "alarmContact": null, + "config": { + "target": [ + "office@example.org", + "archive@example.com" + ] + } } - }, - { - "type": "MANAGED_SERVER", - "identifier": "vm1013", - "caption": "some ManagedServer", - "config": { - "CPU": 2, - "SDD": 512, - "extra": 42 - } - }, - { - "type": "MANAGED_SERVER", - "identifier": "vm1012", - "caption": "some ManagedServer", - "config": { - "CPU": 2, - "SDD": 512, - "extra": 42 - } - } - ] - """)); + ] + """)); // @formatter:on } } @Nested - class AddServer { + @Order(3) + class AddAsset { @Test void globalAdmin_canAddBookedAsset() { context.define("superuser-alex@hostsharing.net"); - final var givenBookingItem = givenBookingItem("First", "some PrivateCloud"); + final var givenBookingItem = newBookingItem("D-1000111 default project", + HsBookingItemType.MANAGED_WEBSPACE, "separate ManagedWebspace BI", + Map.ofEntries( + entry("SSD", 50), + entry("Traffic", 50) + ) + ); + final var givenParentAsset = givenParentAsset(MANAGED_SERVER, "vm1011"); final var location = RestAssured // @formatter:off .given() @@ -173,12 +161,13 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup .body(""" { "bookingItemUuid": "%s", - "type": "MANAGED_SERVER", - "identifier": "vm1400", - "caption": "some new ManagedServer", - "config": { "CPUs": 2, "RAM": 100, "SSD": 300, "Traffic": 250 } + "type": "MANAGED_WEBSPACE", + "identifier": "fir10", + "parentAssetUuid": "%s", + "caption": "some separate ManagedWebspace HA", + "config": {} } - """.formatted(givenBookingItem.getUuid())) + """.formatted(givenBookingItem.getUuid(), givenParentAsset.getUuid())) .port(port) .when() .post("http://localhost/api/hs/hosting/assets") @@ -187,15 +176,59 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup .contentType(ContentType.JSON) .body("", lenientlyEquals(""" { - "type": "MANAGED_SERVER", - "identifier": "vm1400", - "caption": "some new ManagedServer", - "config": { "CPUs": 2, "RAM": 100, "SSD": 300, "Traffic": 250 } + "type": "MANAGED_WEBSPACE", + "identifier": "fir10", + "caption": "some separate ManagedWebspace HA", + "config": {} } """)) .header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/hosting/assets/[^/]*")) .extract().header("Location"); // @formatter:on + // finally, the new asset can be accessed under the generated UUID + final var newWebspace = UUID.fromString( + location.substring(location.lastIndexOf('/') + 1)); + assertThat(newWebspace).isNotNull(); + toCleanup(HsHostingAssetEntity.class, newWebspace); + } + + @Test + void parentAssetAgent_canAddSubAsset() { + + context.define("superuser-alex@hostsharing.net"); + final var givenParentAsset = givenParentAsset(MANAGED_WEBSPACE, "fir01"); + + final var location = RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .header("assumed-roles", "hs_hosting_asset#vm1011:ADMIN") + .contentType(ContentType.JSON) + .body(""" + { + "parentAssetUuid": "%s", + "type": "UNIX_USER", + "identifier": "fir01-temp", + "caption": "some new UnixUser in client's ManagedWebspace", + "config": {} + } + """.formatted(givenParentAsset.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/hosting/assets") + .then().log().all().assertThat() + .statusCode(201) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "type": "UNIX_USER", + "identifier": "fir01-temp", + "caption": "some new UnixUser in client's ManagedWebspace", + "config": {} + } + """)) + .header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/hosting/assets/[^/]*")) + .extract().header("Location"); // @formatter:on + // finally, the new asset can be accessed under the generated UUID final var newUserUuid = UUID.fromString( location.substring(location.lastIndexOf('/') + 1)); @@ -203,24 +236,22 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup } @Test - void parentAssetAgent_canAddSubAsset() { + void globalAdmin_canAddTopLevelAsset() { context.define("superuser-alex@hostsharing.net"); - final var givenParentAsset = givenParentAsset("First", MANAGED_SERVER); final var location = RestAssured // @formatter:off .given() - .header("current-user", "person-FirbySusan@example.com") + .header("current-user", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) .body(""" { - "parentAssetUuid": "%s", - "type": "MANAGED_WEBSPACE", - "identifier": "fir90", - "caption": "some new ManagedWebspace in client's ManagedServer", - "config": { "SSD": 100, "Traffic": 250 } + "type": "DOMAIN_SETUP", + "identifier": "example.com", + "caption": "some unrelated domain-setup", + "config": {} } - """.formatted(givenParentAsset.getUuid())) + """) .port(port) .when() .post("http://localhost/api/hs/hosting/assets") @@ -229,65 +260,122 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup .contentType(ContentType.JSON) .body("", lenientlyEquals(""" { - "type": "MANAGED_WEBSPACE", - "identifier": "fir90", - "caption": "some new ManagedWebspace in client's ManagedServer", - "config": { "SSD": 100, "Traffic": 250 } + "type": "DOMAIN_SETUP", + "identifier": "example.com", + "caption": "some unrelated domain-setup", + "config": {} } """)) .header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/hosting/assets/[^/]*")) .extract().header("Location"); // @formatter:on // finally, the new asset can be accessed under the generated UUID - final var newUserUuid = UUID.fromString( + final var newWebspace = UUID.fromString( location.substring(location.lastIndexOf('/') + 1)); - assertThat(newUserUuid).isNotNull(); + assertThat(newWebspace).isNotNull(); + toCleanup(HsHostingAssetEntity.class, newWebspace); } @Test - void additionalValidationsArePerformend_whenAddingAsset() { + void propertyValidationsArePerformend_whenAddingAsset() { context.define("superuser-alex@hostsharing.net"); - final var givenBookingItem = givenBookingItem("First", "some PrivateCloud"); + final var givenBookingItem = givenSomeNewBookingItem( + "D-1000111 default project", + HsBookingItemType.MANAGED_SERVER, + "some PrivateCloud"); - final var location = RestAssured // @formatter:off + RestAssured // @formatter:off .given() - .header("current-user", "superuser-alex@hostsharing.net") - .contentType(ContentType.JSON) - .body(""" - { - "bookingItemUuid": "%s", - "type": "MANAGED_SERVER", - "identifier": "vm1400", - "caption": "some new ManagedServer", - "config": { "CPUs": 0, "extra": 42 } - } - """.formatted(givenBookingItem.getUuid())) - .port(port) + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "bookingItemUuid": "%s", + "type": "MANAGED_SERVER", + "identifier": "vm1400", + "caption": "some new ManagedServer", + "config": { "monit_max_ssd_usage": 0, "monit_max_cpu_usage": 101, "extra": 42 } + } + """.formatted(givenBookingItem.getUuid())) + .port(port) .when() - .post("http://localhost/api/hs/hosting/assets") + .post("http://localhost/api/hs/hosting/assets") .then().log().all().assertThat() - .statusCode(400) - .contentType(ContentType.JSON) - .body("", lenientlyEquals(""" - { - "statusPhrase": "Bad Request", - "message": "['config.extra' is not expected but is set to '42', 'config.CPUs' is expected to be >= 1 but is 0, 'config.RAM' is required but missing, 'config.SSD' is required but missing, 'config.Traffic' is required but missing]" - } - """)); // @formatter:on + .statusCode(400) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "statusPhrase": "Bad Request", + "message": "[ + <<<'MANAGED_SERVER:vm1400.config.extra' is not expected but is set to '42', + <<<'MANAGED_SERVER:vm1400.config.monit_max_cpu_usage' is expected to be at most 100 but is 101, + <<<'MANAGED_SERVER:vm1400.config.monit_max_ssd_usage' is expected to be at least 10 but is 0 + <<<]" + } + """.replaceAll(" +<<<", ""))); // @formatter:on + } + + @Test + void totalsLimitValidationsArePerformend_whenAddingAsset() { + + context.define("superuser-alex@hostsharing.net"); + final var givenHostingAsset = givenHostingAsset(MANAGED_WEBSPACE, "fir01"); + assertThat(givenHostingAsset.getBookingItem().getResources().get("Multi")) + .as("precondition failed") + .isEqualTo(1); + + jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + for (int n = 0; n < 25; ++n) { + toCleanup(assetRepo.save( + HsHostingAssetEntity.builder() + .type(UNIX_USER) + .parentAsset(givenHostingAsset) + .identifier("fir01-%2d".formatted(n)) + .caption("Test UnixUser fir01-%2d".formatted(n)) + .build())); + } + }).assertSuccessful(); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + .contentType(ContentType.JSON) + .body(""" + { + "parentAssetUuid": "%s", + "type": "UNIX_USER", + "identifier": "fir01-extra", + "caption": "some extra UnixUser", + "config": { } + } + """.formatted(givenHostingAsset.getUuid())) + .port(port) + .when() + .post("http://localhost/api/hs/hosting/assets") + .then().log().all().assertThat() + .statusCode(400) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "statusPhrase": "Bad Request", + "message": "['D-1000111:D-1000111 default project:separate ManagedWebspace.resources.Multi=1 allows at maximum 25 unix users, but 26 found]" + } + """.replaceAll(" +<<<", ""))); // @formatter:on } } @Nested + @Order(1) class GetAsset { @Test void globalAdmin_canGetArbitraryAsset() { context.define("superuser-alex@hostsharing.net"); - final var givenAssetUuid = assetRepo.findAll().stream() - .filter(bi -> bi.getBookingItem().getDebitor().getDebitorNumber() == 1000111) - .filter(item -> item.getCaption().equals("some ManagedServer")) - .findAny().orElseThrow().getUuid(); + final var givenAssetUuid = assetRepo.findByIdentifier("vm1011").stream() + .filter(bi -> bi.getBookingItem().getProject().getCaption().equals("D-1000111 default project")) + .findAny().orElseThrow().getUuid(); RestAssured // @formatter:off .given() @@ -301,11 +389,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup .body("", lenientlyEquals(""" { "caption": "some ManagedServer", - "config": { - "CPU": 2, - "SDD": 512, - "extra": 42 - } + "config": {} } """)); // @formatter:on } @@ -313,8 +397,8 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup @Test void normalUser_canNotGetUnrelatedAsset() { context.define("superuser-alex@hostsharing.net"); - final var givenAssetUuid = assetRepo.findAll().stream() - .filter(bi -> bi.getBookingItem().getDebitor().getDebitorNumber() == 1000212) + final var givenAssetUuid = assetRepo.findByIdentifier("vm1012").stream() + .filter(bi -> bi.getBookingItem().getProject().getCaption().equals("D-1000212 default project")) .map(HsHostingAssetEntity::getUuid) .findAny().orElseThrow(); @@ -331,9 +415,8 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup @Test void debitorAgentUser_canGetRelatedAsset() { context.define("superuser-alex@hostsharing.net"); - final var givenAssetUuid = assetRepo.findAll().stream() - .filter(bi -> bi.getBookingItem().getDebitor().getDebitorNumber() == 1000313) - .filter(bi -> bi.getCaption().equals("some ManagedServer")) + final var givenAssetUuid = assetRepo.findByIdentifier("vm1013").stream() + .filter(bi -> bi.getBookingItem().getProject().getCaption().equals("D-1000313 default project")) .findAny().orElseThrow().getUuid(); RestAssured // @formatter:off @@ -349,24 +432,37 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup { "identifier": "vm1013", "caption": "some ManagedServer", - "config": { - "CPU": 2, - "SDD": 512, - "extra": 42 - } + "config": {} } """)); // @formatter:on } } @Nested + @Order(4) class PatchAsset { @Test void globalAdmin_canPatchAllUpdatablePropertiesOfAsset() { - final var givenAsset = givenSomeTemporaryHostingAsset("2001", CLOUD_SERVER, - config("CPUs", 4), config("RAM", 100), config("HDD", 100), config("Traffic", 2000)); + final var givenAsset = givenSomeTemporaryHostingAsset(() -> + HsHostingAssetEntity.builder() + .uuid(UUID.randomUUID()) + .bookingItem(givenSomeNewBookingItem( + "D-1000111 default project", + HsBookingItemType.MANAGED_SERVER, + "temp ManagedServer")) + .type(MANAGED_SERVER) + .identifier("vm2001") + .caption("some test-asset") + .config(Map.ofEntries( + Map.entry("monit_max_ssd_usage", 80), + Map.entry("monit_max_hdd_usage", 90), + Map.entry("monit_max_cpu_usage", 90), + Map.entry("monit_max_ram_usage", 70) + )) + .build()); + final var alarmContactUuid = givenContact().getUuid(); RestAssured // @formatter:off .given() @@ -374,13 +470,14 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup .contentType(ContentType.JSON) .body(""" { + "alarmContactUuid": "%s", "config": { - "CPUs": 2, - "HDD": null, - "SSD": 250 + "monit_max_ssd_usage": 85, + "monit_max_hdd_usage": null, + "monit_min_free_ssd": 5 } } - """) + """.formatted(alarmContactUuid)) .port(port) .when() .patch("http://localhost/api/hs/hosting/assets/" + givenAsset.getUuid()) @@ -389,36 +486,144 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup .contentType(ContentType.JSON) .body("", lenientlyEquals(""" { - "type": "CLOUD_SERVER", + "type": "MANAGED_SERVER", "identifier": "vm2001", "caption": "some test-asset", + "alarmContact": { + "caption": "second contact", + "emailAddresses": { + "main": "contact-admin@secondcontact.example.com" + } + }, "config": { - "CPUs": 2, - "RAM": 100, - "SSD": 250 + "monit_max_cpu_usage": 90, + "monit_max_ram_usage": 70, + "monit_max_ssd_usage": 85, + "monit_min_free_ssd": 5 } } - """)); // @formatter:on + """)); + // @formatter:on // finally, the asset is actually updated + em.clear(); context.define("superuser-alex@hostsharing.net"); assertThat(assetRepo.findByUuid(givenAsset.getUuid())).isPresent().get() .matches(asset -> { - assertThat(asset.toString()).isEqualTo("HsHostingAssetEntity(CLOUD_SERVER, vm2001, some test-asset, D-1000111:some CloudServer, { CPUs: 2, RAM: 100, SSD: 250, Traffic: 2000 })"); + assertThat(asset.getAlarmContact()).isNotNull() + .extracting(c -> c.getEmailAddresses().get("main")) + .isEqualTo("contact-admin@secondcontact.example.com"); + assertThat(asset.getConfig().toString()) + .isEqualToIgnoringWhitespace(""" + { + "monit_max_cpu_usage": 90, + "monit_max_ram_usage": 70, + "monit_max_ssd_usage": 85, + "monit_min_free_ssd": 5 + } + """); + return true; + }); + } + + @Test + void assetAdmin_canPatchAllUpdatablePropertiesOfAsset() { + + final var givenAsset = givenSomeTemporaryHostingAsset(() -> + HsHostingAssetEntity.builder() + .uuid(UUID.randomUUID()) + .type(UNIX_USER) + .parentAsset(givenHostingAsset(MANAGED_WEBSPACE, "fir01")) + .identifier("fir01-temp") + .caption("some test-unix-user") + .build()); + LinuxEtcShadowHashGenerator.nextSalt("Jr5w/Y8zo8pCkqg7"); + + RestAssured // @formatter:off + .given() + .header("current-user", "superuser-alex@hostsharing.net") + //.header("assumed-roles", "hs_hosting_asset#vm2001:ADMIN") + .contentType(ContentType.JSON) + .body(""" + { + "caption": "some patched test-unix-user", + "config": { + "shell": "/bin/bash", + "totpKey": "0x1234567890abcdef0123456789abcdef", + "password": "Ein Passwort mit 4 Zeichengruppen!" + } + } + """) + .port(port) + .when() + .patch("http://localhost/api/hs/hosting/assets/" + givenAsset.getUuid()) + .then().log().all().assertThat() + .statusCode(200) + .contentType(ContentType.JSON) + .body("", lenientlyEquals(""" + { + "type": "UNIX_USER", + "identifier": "fir01-temp", + "caption": "some patched test-unix-user", + "config": { + "homedir": "/home/pacs/fir01/users/temp", + "shell": "/bin/bash" + } + } + """)) + // the config separately but not-leniently to make sure that no write-only-properties are listed + .body("config", strictlyEquals(""" + { + "homedir": "/home/pacs/fir01/users/temp", + "shell": "/bin/bash" + } + """)) + ; + // @formatter:on + + // finally, the asset is actually updated + assertThat(jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + return assetRepo.findByUuid(givenAsset.getUuid()); + }).returnedValue()).isPresent().get() + .matches(asset -> { + assertThat(asset.getCaption()).isEqualTo("some patched test-unix-user"); + assertThat(asset.getConfig().toString()).isEqualTo(""" + { + "password": "$6$Jr5w/Y8zo8pCkqg7$/rePRbvey3R6Sz/02YTlTQcRt5qdBPTj2h5.hz.rB8NfIoND8pFOjeB7orYcPs9JNf3JDxPP2V.6MQlE5BwAY/", + "shell": "/bin/bash", + "totpKey": "0x1234567890abcdef0123456789abcdef" + } + """); return true; }); } } @Nested + @Order(5) class DeleteAsset { @Test void globalAdmin_canDeleteArbitraryAsset() { context.define("superuser-alex@hostsharing.net"); - final var givenAsset = givenSomeTemporaryHostingAsset("2002", CLOUD_SERVER, - config("CPUs", 4), config("RAM", 100), config("HDD", 100), config("Traffic", 2000)); - + final var givenAsset = givenSomeTemporaryHostingAsset(() -> + HsHostingAssetEntity.builder() + .uuid(UUID.randomUUID()) + .bookingItem(givenSomeNewBookingItem( + "D-1000111 default project", + HsBookingItemType.MANAGED_SERVER, + "temp ManagedServer")) + .type(MANAGED_SERVER) + .identifier("vm1002") + .caption("some test-asset") + .config(Map.ofEntries( + Map.entry("monit_max_ssd_usage", 80), + Map.entry("monit_max_hdd_usage", 90), + Map.entry("monit_max_cpu_usage", 90), + Map.entry("monit_max_ram_usage", 70) + )) + .build()); RestAssured // @formatter:off .given() .header("current-user", "superuser-alex@hostsharing.net") @@ -435,16 +640,30 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup @Test void normalUser_canNotDeleteUnrelatedAsset() { context.define("superuser-alex@hostsharing.net"); - final var givenAsset = givenSomeTemporaryHostingAsset("2003", CLOUD_SERVER, - config("CPUs", 4), config("RAM", 100), config("HDD", 100), config("Traffic", 2000)); - + final var givenAsset = givenSomeTemporaryHostingAsset(() -> + HsHostingAssetEntity.builder() + .uuid(UUID.randomUUID()) + .bookingItem(givenSomeNewBookingItem( + "D-1000111 default project", + HsBookingItemType.MANAGED_SERVER, + "temp ManagedServer")) + .type(MANAGED_SERVER) + .identifier("vm1003") + .caption("some test-asset") + .config(Map.ofEntries( + Map.entry("monit_max_ssd_usage", 80), + Map.entry("monit_max_hdd_usage", 90), + Map.entry("monit_max_cpu_usage", 90), + Map.entry("monit_max_ram_usage", 70) + )) + .build()); RestAssured // @formatter:off .given() .header("current-user", "selfregistered-user-drew@hostsharing.org") .port(port) .when() .delete("http://localhost/api/hs/hosting/assets/" + givenAsset.getUuid()) - .then().log().body().assertThat() + .then().log().all().assertThat() .statusCode(404); // @formatter:on // then the given asset is still there @@ -452,39 +671,72 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup } } - HsBookingItemEntity givenBookingItem(final String debitorName, final String bookingItemCaption) { - final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike(debitorName).stream().findAny().orElseThrow(); - return bookingItemRepo.findAllByDebitorUuid(givenDebitor.getUuid()).stream() - .filter(i -> i.getCaption().equals(bookingItemCaption)) + HsHostingAssetEntity givenHostingAsset(final HsHostingAssetType type, final String identifier) { + return assetRepo.findByIdentifier(identifier).stream() + .filter(ha -> ha.getType() == type) .findAny().orElseThrow(); } - HsHostingAssetEntity givenParentAsset(final String debitorName, final HsHostingAssetType assetType) { - final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike(debitorName).stream().findAny().orElseThrow(); - final var givenAsset = assetRepo.findAllByCriteria(givenDebitor.getUuid(), null, assetType).stream().findAny().orElseThrow(); - return givenAsset; - } - - @SafeVarargs - private HsHostingAssetEntity givenSomeTemporaryHostingAsset(final String identifierSuffix, - final HsHostingAssetType hostingAssetType, - final Map.Entry... resources) { + HsBookingItemEntity newBookingItem( + final String projectCaption, + final HsBookingItemType type, final String bookingItemCaption, final Map resources) { return jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net"); - final var newAsset = HsHostingAssetEntity.builder() - .uuid(UUID.randomUUID()) - .bookingItem(givenBookingItem("First", "some CloudServer")) - .type(hostingAssetType) - .identifier("vm" + identifierSuffix) - .caption("some test-asset") - .config(Map.ofEntries(resources)) + final var project = projectRepo.findByCaption(projectCaption).stream() + .findAny().orElseThrow(); + final var bookingItem = HsBookingItemEntity.builder() + .project(project) + .type(type) + .caption(bookingItemCaption) + .resources(resources) .build(); - - return assetRepo.save(newAsset); + return toCleanup(bookingItemRepo.save(bookingItem)); }).assertSuccessful().returnedValue(); } - private Map.Entry config(final String key, final Object value) { - return entry(key, value); + HsBookingItemEntity givenSomeNewBookingItem( + final String projectCaption, + final HsBookingItemType bookingItemType, + final String bookingItemCaption) { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + final var project = projectRepo.findByCaption(projectCaption).getFirst(); + final var resources = switch (bookingItemType) { + case MANAGED_SERVER -> Map.ofEntries(entry("CPUs", 1), + entry("RAM", 20), + entry("SSD", 25), + entry("Traffic", 250)); + default -> new HashMap(); + }; + final var newBookingItem = HsBookingItemEntity.builder() + .project(project) + .type(bookingItemType) + .caption(bookingItemCaption) + .resources(resources) + .build(); + return toCleanup(bookingItemRepo.save(newBookingItem)); + }).assertSuccessful().returnedValue(); } + + HsHostingAssetEntity givenParentAsset(final HsHostingAssetType assetType, final String assetIdentifier) { + final var givenAsset = assetRepo.findByIdentifier(assetIdentifier).stream() + .filter(a -> a.getType() == assetType) + .findAny().orElseThrow(); + return givenAsset; + } + + private HsHostingAssetEntity givenSomeTemporaryHostingAsset(final Supplier newAsset) { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + return toCleanup(assetRepo.save(newAsset.get())); + }).assertSuccessful().returnedValue(); + } + + private HsOfficeContactEntity givenContact() { + return jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net"); + return contactRepo.findContactByOptionalCaptionLike("second").stream().findFirst().orElseThrow(); + }).returnedValue(); + } + } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java new file mode 100644 index 00000000..4a5bc04c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerRestTest.java @@ -0,0 +1,421 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.SneakyThrows; +import net.hostsharing.hsadminng.context.Context; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRepository; +import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.mapper.Mapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; +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; +import java.util.List; +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_CLOUD_SERVER_BOOKING_ITEM; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_MANAGED_SERVER_BOOKING_ITEM; +import static net.hostsharing.hsadminng.hs.hosting.asset.TestHsHostingAssetEntities.TEST_MANAGED_WEBSPACE_HOSTING_ASSET; +import static net.hostsharing.hsadminng.hs.office.contact.TestHsOfficeContact.TEST_CONTACT; +import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(HsHostingAssetController.class) +@Import(Mapper.class) +@RunWith(SpringRunner.class) +public class HsHostingAssetControllerRestTest { + + @Autowired + MockMvc mockMvc; + + @MockBean + Context contextMock; + + @Autowired + Mapper mapper; + + @Mock + private EntityManager em; + + @MockBean + EntityManagerFactory emf; + + @MockBean + @SuppressWarnings("unused") // bean needs to be present for HsHostingAssetController + private HsBookingItemRepository bookingItemRepo; + + @MockBean + private HsHostingAssetRepository hostingAssetRepo; + + enum ListTestCases { + CLOUD_SERVER( + List.of( + HsHostingAssetEntity.builder() + .type(HsHostingAssetType.CLOUD_SERVER) + .bookingItem(TEST_CLOUD_SERVER_BOOKING_ITEM) + .identifier("vm1234") + .caption("some fake cloud-server") + .alarmContact(TEST_CONTACT) + .build()), + """ + [ + { + "type": "CLOUD_SERVER", + "identifier": "vm1234", + "caption": "some fake cloud-server", + "alarmContact": { + "caption": "some contact", + "postalAddress": "address of some contact", + "emailAddresses": { + "main": "some-contact@example.com" + } + }, + "config": {} + } + ] + """), + MANAGED_SERVER( + List.of( + HsHostingAssetEntity.builder() + .type(HsHostingAssetType.MANAGED_SERVER) + .bookingItem(TEST_MANAGED_SERVER_BOOKING_ITEM) + .identifier("vm1234") + .caption("some fake managed-server") + .alarmContact(TEST_CONTACT) + .config(Map.ofEntries( + entry("monit_max_ssd_usage", 70), + entry("monit_max_cpu_usage", 80), + entry("monit_max_ram_usage", 90) + )) + .build()), + """ + [ + { + "type": "MANAGED_SERVER", + "identifier": "vm1234", + "caption": "some fake managed-server", + "alarmContact": { + "caption": "some contact", + "postalAddress": "address of some contact", + "emailAddresses": { + "main": "some-contact@example.com" + } + }, + "config": { + "monit_max_ssd_usage": 70, + "monit_max_cpu_usage": 80, + "monit_max_ram_usage": 90 + } + } + ] + """), + UNIX_USER( + List.of( + HsHostingAssetEntity.builder() + .type(HsHostingAssetType.UNIX_USER) + .parentAsset(TEST_MANAGED_WEBSPACE_HOSTING_ASSET) + .identifier("xyz00-office") + .caption("some fake Unix-User") + .config(Map.ofEntries( + entry("password", "$6$salt$hashed-salted-password"), + entry("totpKey", "0x0123456789abcdef"), + entry("shell", "/bin/bash"), + entry("SSD-soft-quota", 128), + entry("SSD-hard-quota", 256), + entry("HDD-soft-quota", 256), + entry("HDD-hard-quota", 512))) + .build()), + """ + [ + { + "type": "UNIX_USER", + "identifier": "xyz00-office", + "caption": "some fake Unix-User", + "alarmContact": null, + "config": { + "SSD-soft-quota": 128, + "SSD-hard-quota": 256, + "HDD-soft-quota": 256, + "HDD-hard-quota": 512, + "shell": "/bin/bash", + "homedir": "/home/pacs/xyz00/users/office" + } + } + ] + """), + EMAIL_ALIAS( + List.of( + HsHostingAssetEntity.builder() + .type(HsHostingAssetType.EMAIL_ALIAS) + .parentAsset(TEST_MANAGED_WEBSPACE_HOSTING_ASSET) + .identifier("xyz00-office") + .caption("some fake EMail-Alias") + .config(Map.ofEntries( + entry("target", Array.of("xyz00", "xyz00-abc", "office@example.com")) + )) + .build()), + """ + [ + { + "type": "EMAIL_ALIAS", + "identifier": "xyz00-office", + "caption": "some fake EMail-Alias", + "alarmContact": null, + "config": { + "target": ["xyz00","xyz00-abc","office@example.com"] + } + } + ] + """), + DOMAIN_SETUP( + List.of( + HsHostingAssetEntity.builder() + .type(HsHostingAssetType.DOMAIN_SETUP) + .identifier("example.org") + .caption("some fake Domain-Setup") + .build()), + """ + [ + { + "type": "DOMAIN_SETUP", + "identifier": "example.org", + "caption": "some fake Domain-Setup", + "alarmContact": null, + "config": {} + } + ] + """), + DOMAIN_DNS_SETUP( + List.of( + HsHostingAssetEntity.builder() + .type(HsHostingAssetType.DOMAIN_DNS_SETUP) + .identifier("example.org") + .caption("some fake Domain-DNS-Setup") + .config(Map.ofEntries( + entry("auto-WILDCARD-MX-RR", false), + entry("auto-WILDCARD-A-RR", false), + entry("auto-WILDCARD-AAAA-RR", false), + entry("auto-WILDCARD-DKIM-RR", false), + entry("auto-WILDCARD-SPF-RR", false), + entry("user-RR", Array.of( + "www IN CNAME example.com. ; www.example.com is an alias for example.com", + "test1 IN 1h30m CNAME example.com.", + "test2 1h30m IN CNAME example.com.", + "ns IN A 192.0.2.2; IPv4 address for ns.example.com") + ) + )) + .build()), + """ + [ + { + "type": "DOMAIN_DNS_SETUP", + "identifier": "example.org", + "caption": "some fake Domain-DNS-Setup", + "alarmContact": null, + "config": { + "auto-WILDCARD-AAAA-RR": false, + "auto-WILDCARD-MX-RR": false, + "auto-WILDCARD-SPF-RR": false, + "auto-WILDCARD-DKIM-RR": false, + "auto-WILDCARD-A-RR": false, + "user-RR": [ + "www IN CNAME example.com. ; www.example.com is an alias for example.com", + "test1 IN 1h30m CNAME example.com.", + "test2 1h30m IN CNAME example.com.", + "ns IN A 192.0.2.2; IPv4 address for ns.example.com" + ] + } + } + ] + """), + DOMAIN_HTTP_SETUP( + List.of( + HsHostingAssetEntity.builder() + .type(HsHostingAssetType.DOMAIN_HTTP_SETUP) + .identifier("example.org|HTTP") + .caption("some fake Domain-HTTP-Setup") + .config(Map.ofEntries( + entry("htdocsfallback", false), + entry("indexes", false), + entry("cgi", false), + entry("passenger", false), + entry("passenger-errorpage", true), + entry("fastcgi", false), + entry("autoconfig", false), + entry("greylisting", false), + entry("includes", false), + entry("letsencrypt", false), + entry("multiviews", false), + entry("fcgi-php-bin", "/usr/lib/cgi-bin/php8"), + entry("passenger-nodejs", "/usr/bin/node-js7"), + entry("passenger-python", "/usr/bin/python6"), + entry("passenger-ruby", "/usr/bin/ruby5"), + entry("subdomains", Array.of("www", "test1", "test2")) + )) + .build()), + """ + [ + { + "type": "DOMAIN_HTTP_SETUP", + "identifier": "example.org|HTTP", + "caption": "some fake Domain-HTTP-Setup", + "alarmContact": null, + "config": { + "autoconfig": false, + "cgi": false, + "fastcgi": false, + "greylisting": false, + "htdocsfallback": false, + "includes": false, + "indexes": false, + "letsencrypt": false, + "multiviews": false, + "passenger": false, + "passenger-errorpage": true, + "passenger-nodejs": "/usr/bin/node-js7", + "passenger-python": "/usr/bin/python6", + "passenger-ruby": "/usr/bin/ruby5", + "fcgi-php-bin": "/usr/lib/cgi-bin/php8", + "subdomains": ["www","test1","test2"] + } + } + ] + """), + DOMAIN_SMTP_SETUP( + List.of( + HsHostingAssetEntity.builder() + .type(HsHostingAssetType.DOMAIN_SMTP_SETUP) + .identifier("example.org|SMTP") + .caption("some fake Domain-SMTP-Setup") + .build()), + """ + [ + { + "type": "DOMAIN_SMTP_SETUP", + "identifier": "example.org|SMTP", + "caption": "some fake Domain-SMTP-Setup", + "alarmContact": null, + "config": {} + } + ] + """), + DOMAIN_MBOX_SETUP( + List.of( + HsHostingAssetEntity.builder() + .type(HsHostingAssetType.DOMAIN_MBOX_SETUP) + .identifier("example.org|MBOX") + .caption("some fake Domain-MBOX-Setup") + .build()), + """ + [ + { + "type": "DOMAIN_MBOX_SETUP", + "identifier": "example.org|MBOX", + "caption": "some fake Domain-MBOX-Setup", + "alarmContact": null, + "config": {} + } + ] + """), + EMAIL_ADDRESS( + List.of( + HsHostingAssetEntity.builder() + .type(HsHostingAssetType.EMAIL_ADDRESS) + .parentAsset(HsHostingAssetEntity.builder() + .type(HsHostingAssetType.DOMAIN_MBOX_SETUP) + .identifier("example.org|MBOX") + .caption("some fake Domain-MBOX-Setup") + .build()) + .identifier("office@example.org") + .caption("some fake EMail-Address") + .config(Map.ofEntries( + entry("target", Array.of("xyz00", "xyz00-abc", "office@example.com")) + )) + .build()), + """ + [ + { + "type": "EMAIL_ADDRESS", + "identifier": "office@example.org", + "caption": "some fake EMail-Address", + "alarmContact": null, + "config": { + "target": ["xyz00","xyz00-abc","office@example.com"] + } + } + ] + """); + + final HsHostingAssetType assetType; + final List givenHostingAssetsOfType; + final String expectedResponse; + final JsonNode expectedResponseJson; + + @SneakyThrows + ListTestCases( + final List givenHostingAssetsOfType, + final String expectedResponse) { + this.assetType = HsHostingAssetType.valueOf(name()); + this.givenHostingAssetsOfType = givenHostingAssetsOfType; + this.expectedResponse = expectedResponse; + this.expectedResponseJson = new ObjectMapper().readTree(expectedResponse); + } + + @SneakyThrows + JsonNode expectedConfig(final int n) { + return expectedResponseJson.get(n).path("config"); + } + } + + @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); + } + + @ParameterizedTest + @EnumSource(HsHostingAssetControllerRestTest.ListTestCases.class) + void shouldListAssets(final HsHostingAssetControllerRestTest.ListTestCases testCase) throws Exception { + // given + when(hostingAssetRepo.findAllByCriteria(null, null, testCase.assetType)) + .thenReturn(testCase.givenHostingAssetsOfType); + + // when + final var result = mockMvc.perform(MockMvcRequestBuilders + .get("/api/hs/hosting/assets?type="+testCase.name()) + .header("current-user", "superuser-alex@hostsharing.net") + .accept(MediaType.APPLICATION_JSON)) + + // then + .andExpect(status().is2xxSuccessful()) + .andExpect(jsonPath("$", lenientlyEquals(testCase.expectedResponse))) + .andReturn(); + + // and the config properties do match not just leniently but even strictly + final var resultBody = new ObjectMapper().readTree(result.getResponse().getContentAsString()); + for (int n = 0; n < resultBody.size(); ++n) { + assertThat(resultBody.get(n).path("config")).isEqualTo(testCase.expectedConfig(n)); + } + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcherUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcherUnitTest.java index d726c9b4..96728cca 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcherUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityPatcherUnitTest.java @@ -1,7 +1,7 @@ package net.hostsharing.hsadminng.hs.hosting.asset; import net.hostsharing.hsadminng.hs.hosting.generated.api.v1.model.HsHostingAssetPatchResource; -import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; +import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; import net.hostsharing.hsadminng.mapper.KeyValueMap; import net.hostsharing.hsadminng.rbac.test.PatchUnitTestBase; import org.junit.jupiter.api.BeforeEach; @@ -15,7 +15,7 @@ import java.util.Map; import java.util.UUID; import java.util.stream.Stream; -import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_BOOKING_ITEM; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_CLOUD_SERVER_BOOKING_ITEM; import static net.hostsharing.hsadminng.mapper.PatchMap.entry; import static net.hostsharing.hsadminng.mapper.PatchMap.patchMap; import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; @@ -31,6 +31,7 @@ class HsHostingAssetEntityPatcherUnitTest extends PatchUnitTestBase< > { private static final UUID INITIAL_BOOKING_ITEM_UUID = UUID.randomUUID(); + private static final UUID PATCHED_CONTACT_UUID = UUID.randomUUID(); private static final Map INITIAL_CONFIG = patchMap( entry("CPU", 1), @@ -40,13 +41,16 @@ class HsHostingAssetEntityPatcherUnitTest extends PatchUnitTestBase< private static final Map PATCH_CONFIG = patchMap( entry("CPU", 2), entry("HDD", null), - entry("SDD", 256) + entry("SSD", 256) ); private static final Map PATCHED_CONFIG = patchMap( entry("CPU", 2), - entry("SDD", 256), + entry("SSD", 256), entry("MEM", 64) ); + final HsOfficeContactEntity givenInitialContact = HsOfficeContactEntity.builder() + .uuid(UUID.randomUUID()) + .build(); private static final String INITIAL_CAPTION = "initial caption"; private static final String PATCHED_CAPTION = "patched caption"; @@ -56,19 +60,20 @@ class HsHostingAssetEntityPatcherUnitTest extends PatchUnitTestBase< @BeforeEach void initMocks() { - lenient().when(em.getReference(eq(HsOfficeDebitorEntity.class), any())).thenAnswer(invocation -> - HsOfficeDebitorEntity.builder().uuid(invocation.getArgument(1)).build()); lenient().when(em.getReference(eq(HsHostingAssetEntity.class), any())).thenAnswer(invocation -> HsHostingAssetEntity.builder().uuid(invocation.getArgument(1)).build()); + lenient().when(em.getReference(eq(HsOfficeContactEntity.class), any())).thenAnswer(invocation -> + HsOfficeContactEntity.builder().uuid(invocation.getArgument(1)).build()); } @Override protected HsHostingAssetEntity newInitialEntity() { final var entity = new HsHostingAssetEntity(); entity.setUuid(INITIAL_BOOKING_ITEM_UUID); - entity.setBookingItem(TEST_BOOKING_ITEM); + entity.setBookingItem(TEST_CLOUD_SERVER_BOOKING_ITEM); entity.getConfig().putAll(KeyValueMap.from(INITIAL_CONFIG)); entity.setCaption(INITIAL_CAPTION); + entity.setAlarmContact(givenInitialContact); return entity; } @@ -79,7 +84,7 @@ class HsHostingAssetEntityPatcherUnitTest extends PatchUnitTestBase< @Override protected HsHostingAssetEntityPatcher createPatcher(final HsHostingAssetEntity server) { - return new HsHostingAssetEntityPatcher(server); + return new HsHostingAssetEntityPatcher(em, server); } @Override @@ -96,7 +101,17 @@ class HsHostingAssetEntityPatcherUnitTest extends PatchUnitTestBase< PATCH_CONFIG, HsHostingAssetEntity::putConfig, PATCHED_CONFIG) - .notNullable() + .notNullable(), + new JsonNullableProperty<>( + "alarmContact", + HsHostingAssetPatchResource::setAlarmContactUuid, + PATCHED_CONTACT_UUID, + HsHostingAssetEntity::setAlarmContact, + newContact(PATCHED_CONTACT_UUID)) ); } + + static HsOfficeContactEntity newContact(final UUID uuid) { + return HsOfficeContactEntity.builder().uuid(uuid).build(); + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityUnitTest.java index 2f0fc00a..6460ae39 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetEntityUnitTest.java @@ -5,13 +5,13 @@ import org.junit.jupiter.api.Test; import java.util.Map; import static java.util.Map.entry; -import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_BOOKING_ITEM; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_CLOUD_SERVER_BOOKING_ITEM; import static org.assertj.core.api.Assertions.assertThat; class HsHostingAssetEntityUnitTest { final HsHostingAssetEntity givenParentAsset = HsHostingAssetEntity.builder() - .bookingItem(TEST_BOOKING_ITEM) + .bookingItem(TEST_CLOUD_SERVER_BOOKING_ITEM) .type(HsHostingAssetType.MANAGED_SERVER) .identifier("vm1234") .caption("some managed asset") @@ -20,8 +20,8 @@ class HsHostingAssetEntityUnitTest { entry("SSD-storage", 512), entry("HDD-storage", 2048))) .build(); - final HsHostingAssetEntity givenServer = HsHostingAssetEntity.builder() - .bookingItem(TEST_BOOKING_ITEM) + final HsHostingAssetEntity givenWebspace = HsHostingAssetEntity.builder() + .bookingItem(TEST_CLOUD_SERVER_BOOKING_ITEM) .type(HsHostingAssetType.MANAGED_WEBSPACE) .parentAsset(givenParentAsset) .identifier("xyz00") @@ -31,19 +31,47 @@ class HsHostingAssetEntityUnitTest { entry("SSD-storage", 512), entry("HDD-storage", 2048))) .build(); + final HsHostingAssetEntity givenUnixUser = HsHostingAssetEntity.builder() + .type(HsHostingAssetType.UNIX_USER) + .parentAsset(givenWebspace) + .identifier("xyz00-web") + .caption("some unix-user") + .config(Map.ofEntries( + entry("SSD-soft-quota", 128), + entry("SSD-hard-quota", 256), + entry("HDD-soft-quota", 256), + entry("HDD-hard-quota", 512))) + .build(); + final HsHostingAssetEntity givenDomainHttpSetup = HsHostingAssetEntity.builder() + .type(HsHostingAssetType.DOMAIN_HTTP_SETUP) + .parentAsset(givenWebspace) + .identifier("example.org") + .assignedToAsset(givenUnixUser) + .caption("some domain setup") + .config(Map.ofEntries( + entry("option-htdocsfallback", true), + entry("use-fcgiphpbin", "/usr/lib/cgi-bin/php"), + entry("validsubdomainnames", "*"))) + .build(); @Test void toStringContainsAllPropertiesAndResourcesSortedByKey() { - final var result = givenServer.toString(); - assertThat(result).isEqualTo( - "HsHostingAssetEntity(MANAGED_WEBSPACE, xyz00, some managed webspace, MANAGED_SERVER:vm1234, D-1000100:test booking item, { CPUs: 2, HDD-storage: 2048, SSD-storage: 512 })"); + assertThat(givenWebspace.toString()).isEqualToIgnoringWhitespace( + "HsHostingAssetEntity(MANAGED_WEBSPACE, xyz00, some managed webspace, MANAGED_SERVER:vm1234, D-1234500:test project:test cloud server booking item, { \"CPUs\": 2, \"HDD-storage\": 2048, \"SSD-storage\": 512 })"); + + assertThat(givenUnixUser.toString()).isEqualToIgnoringWhitespace( + "HsHostingAssetEntity(UNIX_USER, xyz00-web, some unix-user, MANAGED_WEBSPACE:xyz00, { \"HDD-hard-quota\": 512, \"HDD-soft-quota\": 256, \"SSD-hard-quota\": 256, \"SSD-soft-quota\": 128 })"); + + assertThat(givenDomainHttpSetup.toString()).isEqualToIgnoringWhitespace( + "HsHostingAssetEntity(DOMAIN_HTTP_SETUP, example.org, some domain setup, MANAGED_WEBSPACE:xyz00, UNIX_USER:xyz00-web, { \"option-htdocsfallback\": true, \"use-fcgiphpbin\": \"/usr/lib/cgi-bin/php\", \"validsubdomainnames\": \"*\" })"); } @Test void toShortStringContainsOnlyMemberNumberAndCaption() { - final var result = givenServer.toShortString(); - assertThat(result).isEqualTo("MANAGED_WEBSPACE:xyz00"); + assertThat(givenWebspace.toShortString()).isEqualTo("MANAGED_WEBSPACE:xyz00"); + assertThat(givenUnixUser.toShortString()).isEqualTo("UNIX_USER:xyz00-web"); + assertThat(givenDomainHttpSetup.toShortString()).isEqualTo("DOMAIN_HTTP_SETUP:example.org"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsControllerAcceptanceTest.java index e6cc9acd..290777ea 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetPropsControllerAcceptanceTest.java @@ -33,7 +33,15 @@ class HsHostingAssetPropsControllerAcceptanceTest { [ "MANAGED_SERVER", "MANAGED_WEBSPACE", - "CLOUD_SERVER" + "CLOUD_SERVER", + "UNIX_USER", + "EMAIL_ALIAS", + "DOMAIN_SETUP", + "DOMAIN_DNS_SETUP", + "DOMAIN_HTTP_SETUP", + "DOMAIN_SMTP_SETUP", + "DOMAIN_MBOX_SETUP", + "EMAIL_ADDRESS" ] """)); // @formatter:on @@ -54,52 +62,141 @@ class HsHostingAssetPropsControllerAcceptanceTest { [ { "type": "integer", - "propertyName": "CPUs", - "required": true, - "unit": null, - "min": 1, - "max": 32, - "step": null + "propertyName": "monit_max_cpu_usage", + "unit": "%", + "min": 10, + "max": 100, + "defaultValue": 92 }, { "type": "integer", - "propertyName": "RAM", - "required": true, - "unit": "GB", - "min": 1, - "max": 128, - "step": null + "propertyName": "monit_max_ram_usage", + "unit": "%", + "min": 10, + "max": 100, + "defaultValue": 92 }, { "type": "integer", - "propertyName": "SSD", - "required": true, - "unit": "GB", - "min": 25, + "propertyName": "monit_max_ssd_usage", + "unit": "%", + "min": 10, + "max": 100, + "defaultValue": 98 + }, + { + "type": "integer", + "propertyName": "monit_min_free_ssd", + "min": 1, "max": 1000, - "step": 25 + "defaultValue": 5 }, { "type": "integer", - "propertyName": "HDD", - "required": false, - "unit": "GB", - "min": 0, + "propertyName": "monit_max_hdd_usage", + "unit": "%", + "min": 10, + "max": 100, + "defaultValue": 95 + }, + { + "type": "integer", + "propertyName": "monit_min_free_hdd", + "min": 1, "max": 4000, - "step": 250 + "defaultValue": 10 }, { - "type": "integer", - "propertyName": "Traffic", - "required": true, - "unit": "GB", - "min": 250, - "max": 10000, - "step": 250 + "type": "boolean", + "propertyName": "software-pgsql", + "defaultValue": true + }, + { + "type": "boolean", + "propertyName": "software-mariadb", + "defaultValue": true + }, + { + "type": "enumeration", + "propertyName": "php-default", + "values": [ + "5.6", + "7.0", + "7.1", + "7.2", + "7.3", + "7.4", + "8.0", + "8.1", + "8.2" + ], + "defaultValue": "8.2" + }, + { + "type": "boolean", + "propertyName": "software-php-5.6" + }, + { + "type": "boolean", + "propertyName": "software-php-7.0" + }, + { + "type": "boolean", + "propertyName": "software-php-7.1" + }, + { + "type": "boolean", + "propertyName": "software-php-7.2" + }, + { + "type": "boolean", + "propertyName": "software-php-7.3" + }, + { + "type": "boolean", + "propertyName": "software-php-7.4", + "defaultValue": true + }, + { + "type": "boolean", + "propertyName": "software-php-8.0" + }, + { + "type": "boolean", + "propertyName": "software-php-8.1" + }, + { + "type": "boolean", + "propertyName": "software-php-8.2", + "defaultValue": true + }, + { + "type": "boolean", + "propertyName": "software-postfix-tls-1.0" + }, + { + "type": "boolean", + "propertyName": "software-dovecot-tls-1.0" + }, + { + "type": "boolean", + "propertyName": "software-clamav", + "defaultValue": true + }, + { + "type": "boolean", + "propertyName": "software-collabora" + }, + { + "type": "boolean", + "propertyName": "software-libreoffice" + }, + { + "type": "boolean", + "propertyName": "software-imagemagick-ghostscript" } ] """)); // @formatter:on } - } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepositoryIntegrationTest.java index 83a07599..40f38d7b 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetRepositoryIntegrationTest.java @@ -3,10 +3,11 @@ package net.hostsharing.hsadminng.hs.hosting.asset; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRepository; -import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRepository; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; @@ -26,11 +27,12 @@ import java.util.Map; import static java.util.Map.entry; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; -import static net.hostsharing.hsadminng.rbac.test.Array.fromFormatted; +import static net.hostsharing.hsadminng.mapper.Array.fromFormatted; import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @@ -45,7 +47,7 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu HsBookingItemRepository bookingItemRepo; @Autowired - HsOfficeDebitorRepository debitorRepo; + HsBookingProjectRepository projectRepo; @Autowired RawRbacRoleRepository rawRoleRepo; @@ -70,12 +72,13 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu // given context("superuser-alex@hostsharing.net"); final var count = assetRepo.count(); - final var givenManagedServer = givenManagedServer("First", MANAGED_SERVER); + final var givenManagedServer = givenHostingAsset("D-1000111 default project", MANAGED_SERVER); + final var newWebspaceBookingItem = newBookingItem(givenManagedServer.getBookingItem(), HsBookingItemType.MANAGED_WEBSPACE, "fir01"); // when final var result = attempt(em, () -> { final var newAsset = HsHostingAssetEntity.builder() - .bookingItem(givenManagedServer.getBookingItem()) + .bookingItem(newWebspaceBookingItem) .parentAsset(givenManagedServer) .caption("some new managed webspace") .type(MANAGED_WEBSPACE) @@ -88,6 +91,7 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu result.assertSuccessful(); assertThat(result.returnedValue()).isNotNull().extracting(HsHostingAssetEntity::getUuid).isNotNull(); assertThatAssetIsPersisted(result.returnedValue()); + assertThat(result.returnedValue().isLoaded()).isFalse(); assertThat(assetRepo.count()).isEqualTo(count + 1); } @@ -95,18 +99,19 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu public void createsAndGrantsRoles() { // given context("superuser-alex@hostsharing.net"); + final var givenManagedServer = givenHostingAsset("D-1000111 default project", MANAGED_SERVER); + final var newWebspaceBookingItem = newBookingItem(givenManagedServer.getBookingItem(), HsBookingItemType.MANAGED_WEBSPACE, "fir01"); + em.flush(); final var initialRoleNames = distinctRoleNamesOf(rawRoleRepo.findAll()); - final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()).stream() - .map(s -> s.replace("hs_office_", "")) - .toList(); - final var givenBookingItem = givenBookingItem("First", "some PrivateCloud"); + final var initialGrantNames = distinctGrantDisplaysOf(rawGrantRepo.findAll()); // when final var result = attempt(em, () -> { final var newAsset = HsHostingAssetEntity.builder() - .bookingItem(givenBookingItem) - .type(HsHostingAssetType.MANAGED_SERVER) - .identifier("vm9000") + .bookingItem(newWebspaceBookingItem) + .parentAsset(givenManagedServer) + .type(HsHostingAssetType.MANAGED_WEBSPACE) + .identifier("fir00") .caption("some new managed webspace") .build(); return toCleanup(assetRepo.save(newAsset)); @@ -117,34 +122,75 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu final var all = rawRoleRepo.findAll(); assertThat(distinctRoleNamesOf(all)).containsExactlyInAnyOrder(Array.from( initialRoleNames, - "hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:ADMIN", - "hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:OWNER", - "hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:TENANT")); + "hs_hosting_asset#fir00:ADMIN", + "hs_hosting_asset#fir00:AGENT", + "hs_hosting_asset#fir00:OWNER", + "hs_hosting_asset#fir00:TENANT")); assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll())) - .map(s -> s.replace("hs_office_", "")) .containsExactlyInAnyOrder(fromFormatted( initialGrantNames, + // global-admin + "{ grant role:hs_hosting_asset#fir00:OWNER to role:global#global:ADMIN by system }", // workaround + // owner - "{ grant perm:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:DELETE to role:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:OWNER by system and assume }", - "{ grant role:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:OWNER to role:hs_booking_item#D-1000111-somePrivateCloud:ADMIN by system and assume }", + "{ grant role:hs_hosting_asset#fir00:OWNER to user:superuser-alex@hostsharing.net by hs_hosting_asset#fir00:OWNER and assume }", + "{ grant role:hs_hosting_asset#fir00:OWNER to role:hs_booking_item#fir01:ADMIN by system and assume }", + "{ grant role:hs_hosting_asset#fir00:OWNER to role:hs_hosting_asset#vm1011:ADMIN by system and assume }", + "{ grant perm:hs_hosting_asset#fir00:DELETE to role:hs_hosting_asset#fir00:OWNER by system and assume }", // admin - "{ grant perm:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:INSERT>hs_hosting_asset to role:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:ADMIN by system and assume }", - "{ grant perm:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:UPDATE to role:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:ADMIN by system and assume }", - "{ grant role:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:ADMIN to role:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:OWNER by system and assume }", + "{ grant role:hs_hosting_asset#fir00:ADMIN to role:hs_hosting_asset#fir00:OWNER by system and assume }", + "{ grant role:hs_hosting_asset#fir00:ADMIN to role:hs_booking_item#fir01:AGENT by system and assume }", + "{ grant perm:hs_hosting_asset#fir00:UPDATE to role:hs_hosting_asset#fir00:ADMIN by system and assume }", + + // agent + "{ grant role:hs_hosting_asset#fir00:ADMIN to role:hs_hosting_asset#vm1011:AGENT by system and assume }", + "{ grant role:hs_hosting_asset#fir00:AGENT to role:hs_hosting_asset#fir00:ADMIN by system and assume }", // tenant - "{ grant perm:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:SELECT to role:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:TENANT by system and assume }", - "{ grant role:hs_booking_item#D-1000111-somePrivateCloud:TENANT to role:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:TENANT by system and assume }", - "{ grant role:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:TENANT to role:hs_hosting_asset#D-1000111-somePrivateCloud-vm9000:ADMIN by system and assume }", + "{ grant role:hs_booking_item#fir01:TENANT to role:hs_hosting_asset#fir00:TENANT by system and assume }", + "{ grant role:hs_hosting_asset#fir00:TENANT to role:hs_hosting_asset#fir00:AGENT by system and assume }", + "{ grant role:hs_hosting_asset#vm1011:TENANT to role:hs_hosting_asset#fir00:TENANT by system and assume }", + "{ grant perm:hs_hosting_asset#fir00:SELECT to role:hs_hosting_asset#fir00:TENANT by system and assume }", // workaround null)); } + @Test + public void anyUser_canCreateNewDomainSetupAsset() { + // when + context("person-SmithPeter@example.com"); + final var result = attempt(em, () -> { + final var newAsset = HsHostingAssetEntity.builder() + .type(DOMAIN_SETUP) + .identifier("example.net") + .caption("some new domain setup") + .build(); + return assetRepo.save(newAsset); + }); + + // then + // ... the domain setup was created and returned + result.assertSuccessful(); + assertThat(result.returnedValue()).isNotNull().extracting(HsHostingAssetEntity::getUuid).isNotNull(); + assertThat(result.returnedValue().isLoaded()).isFalse(); + + // ... the creating user can read the new domain setup + context("person-SmithPeter@example.com"); + assertThatAssetIsPersisted(result.returnedValue()); + + // ... a global admin can see the new domain setup as well if the domain OWNER role is assumed + context("superuser-alex@hostsharing.net", "hs_hosting_asset#example.net:OWNER"); // only works with the assumed role + assertThatAssetIsPersisted(result.returnedValue()); + } + private void assertThatAssetIsPersisted(final HsHostingAssetEntity saved) { - final var found = assetRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().map(HsHostingAssetEntity::toString).get().isEqualTo(saved.toString()); + em.clear(); + attempt(em, () -> { + final var found = assetRepo.findByUuid(saved.getUuid()); + assertThat(found).isNotEmpty().map(HsHostingAssetEntity::toString).contains(saved.toString()); + }); } } @@ -160,46 +206,47 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu final var result = assetRepo.findAllByCriteria(null, null, MANAGED_WEBSPACE); // then - allTheseServersAreReturned( + exactlyTheseAssetsAreReturned( result, - "HsHostingAssetEntity(MANAGED_WEBSPACE, bbb01, some Webspace, MANAGED_SERVER:vm1012, D-1000212:some ManagedServer, { HDD: 2048, RAM: 1, SDD: 512, extra: 42 })", - "HsHostingAssetEntity(MANAGED_WEBSPACE, aaa01, some Webspace, MANAGED_SERVER:vm1011, D-1000111:some ManagedServer, { HDD: 2048, RAM: 1, SDD: 512, extra: 42 })", - "HsHostingAssetEntity(MANAGED_WEBSPACE, ccc01, some Webspace, MANAGED_SERVER:vm1013, D-1000313:some ManagedServer, { HDD: 2048, RAM: 1, SDD: 512, extra: 42 })"); + "HsHostingAssetEntity(MANAGED_WEBSPACE, sec01, some Webspace, MANAGED_SERVER:vm1012, D-1000212:D-1000212 default project:separate ManagedWebspace)", + "HsHostingAssetEntity(MANAGED_WEBSPACE, fir01, some Webspace, MANAGED_SERVER:vm1011, D-1000111:D-1000111 default project:separate ManagedWebspace)", + "HsHostingAssetEntity(MANAGED_WEBSPACE, thi01, some Webspace, MANAGED_SERVER:vm1013, D-1000313:D-1000313 default project:separate ManagedWebspace)"); } @Test public void normalUser_canViewOnlyRelatedAsset() { // given: context("person-FirbySusan@example.com"); - final var debitorUuid = debitorRepo.findDebitorByDebitorNumber(1000111).stream().findAny().orElseThrow().getUuid(); + final var projectUuid = projectRepo.findByCaption("D-1000111 default project").stream() + .findAny().orElseThrow().getUuid(); // when: - final var result = assetRepo.findAllByCriteria(debitorUuid, null, null); + final var result = assetRepo.findAllByCriteria(projectUuid, null, null); // then: exactlyTheseAssetsAreReturned( result, - "HsHostingAssetEntity(MANAGED_WEBSPACE, aaa01, some Webspace, MANAGED_SERVER:vm1011, D-1000111:some ManagedServer, { HDD: 2048, RAM: 1, SDD: 512, extra: 42 })", - "HsHostingAssetEntity(MANAGED_SERVER, vm1011, some ManagedServer, D-1000111:some PrivateCloud, { CPU: 2, SDD: 512, extra: 42 })", - "HsHostingAssetEntity(CLOUD_SERVER, vm2011, another CloudServer, D-1000111:some PrivateCloud, { CPU: 2, HDD: 1024, extra: 42 })"); + "HsHostingAssetEntity(MANAGED_WEBSPACE, fir01, some Webspace, MANAGED_SERVER:vm1011, D-1000111:D-1000111 default project:separate ManagedWebspace)", + "HsHostingAssetEntity(MANAGED_SERVER, vm1011, some ManagedServer, D-1000111:D-1000111 default project:separate ManagedServer, { monit_max_cpu_usage: 90, monit_max_ram_usage: 80, monit_max_ssd_usage: 70 } )"); } @Test public void normalUser_canFilterAssetsRelatedToParentAsset() { // given context("superuser-alex@hostsharing.net"); - final var parentAssetUuid = assetRepo.findAllByCriteria(null, null, MANAGED_SERVER).stream() + final var parentAssetUuid = assetRepo.findByIdentifier("vm1012").stream() + .filter(ha -> ha.getType() == MANAGED_SERVER) .findAny().orElseThrow().getUuid(); // when + context("superuser-alex@hostsharing.net", "hs_hosting_asset#vm1012:AGENT"); final var result = assetRepo.findAllByCriteria(null, parentAssetUuid, null); // then - allTheseServersAreReturned( + exactlyTheseAssetsAreReturned( result, - "HsHostingAssetEntity(MANAGED_WEBSPACE, aaa01, some Webspace, MANAGED_SERVER:vm1011, D-1000111:some ManagedServer, { HDD: 2048, RAM: 1, SDD: 512, extra: 42 })"); + "HsHostingAssetEntity(MANAGED_WEBSPACE, sec01, some Webspace, MANAGED_SERVER:vm1012, D-1000212:D-1000212 default project:separate ManagedWebspace)"); } - } @Nested @@ -208,7 +255,7 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu @Test public void hostsharingAdmin_canUpdateArbitraryServer() { // given - final var givenAssetUuid = givenSomeTemporaryAsset("First", "vm1000").getUuid(); + final var givenAssetUuid = givenSomeTemporaryAsset("D-1000111 default project", "vm1000").getUuid(); // when final var result = jpaAttempt.transacted(() -> { @@ -231,7 +278,7 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu private void assertThatAssetActuallyInDatabase(final HsHostingAssetEntity saved) { final var found = assetRepo.findByUuid(saved.getUuid()); assertThat(found).isNotEmpty().get().isNotSameAs(saved) - .extracting(Object::toString).isEqualTo(saved.toString()); + .extracting(HsHostingAssetEntity::getVersion).isEqualTo(saved.getVersion()); } } @@ -242,7 +289,7 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu public void globalAdmin_withoutAssumedRole_canDeleteAnyAsset() { // given context("superuser-alex@hostsharing.net", null); - final var givenAsset = givenSomeTemporaryAsset("First", "vm1000"); + final var givenAsset = givenSomeTemporaryAsset("D-1000111 default project", "vm1000"); // when final var result = jpaAttempt.transacted(() -> { @@ -262,7 +309,7 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu public void relatedOwner_canDeleteTheirRelatedAsset() { // given context("superuser-alex@hostsharing.net", null); - final var givenAsset = givenSomeTemporaryAsset("First", "vm1000"); + final var givenAsset = givenSomeTemporaryAsset("D-1000111 default project", "vm1000"); // when final var result = jpaAttempt.transacted(() -> { @@ -284,11 +331,11 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu public void relatedAdmin_canNotDeleteTheirRelatedAsset() { // given context("superuser-alex@hostsharing.net", null); - final var givenAsset = givenSomeTemporaryAsset("First", "vm1000"); + final var givenAsset = givenSomeTemporaryAsset("D-1000111 default project", "vm1000"); // when final var result = jpaAttempt.transacted(() -> { - context("person-FirbySusan@example.com", "hs_hosting_asset#D-1000111-someCloudServer-vm1000:ADMIN"); + context("person-FirbySusan@example.com", "hs_hosting_asset#vm1000:ADMIN"); assertThat(assetRepo.findByUuid(givenAsset.getUuid())).isPresent(); assetRepo.deleteByUuid(givenAsset.getUuid()); @@ -310,7 +357,7 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu context("superuser-alex@hostsharing.net"); final var initialRoleNames = Array.from(distinctRoleNamesOf(rawRoleRepo.findAll())); final var initialGrantNames = Array.from(distinctGrantDisplaysOf(rawGrantRepo.findAll())); - final var givenAsset = givenSomeTemporaryAsset("First", "vm1000"); + final var givenAsset = givenSomeTemporaryAsset("D-1000111 default project", "vm1000"); // when final var result = jpaAttempt.transacted(() -> { @@ -340,15 +387,15 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu // then assertThat(customerLogEntries).map(Arrays::toString).contains( - "[creating hosting-asset test-data 1000111, hs_hosting_asset, INSERT]", - "[creating hosting-asset test-data 1000212, hs_hosting_asset, INSERT]", - "[creating hosting-asset test-data 1000313, hs_hosting_asset, INSERT]"); + "[creating hosting-asset test-data D-1000111 default project, hs_hosting_asset, INSERT]", + "[creating hosting-asset test-data D-1000212 default project, hs_hosting_asset, INSERT]", + "[creating hosting-asset test-data D-1000313 default project, hs_hosting_asset, INSERT]"); } - private HsHostingAssetEntity givenSomeTemporaryAsset(final String debitorName, final String identifier) { + private HsHostingAssetEntity givenSomeTemporaryAsset(final String projectCaption, final String identifier) { return jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); - final var givenBookingItem = givenBookingItem(debitorName, "some CloudServer"); + final var givenBookingItem = givenBookingItem("D-1000111 default project", "test CloudServer"); final var newAsset = HsHostingAssetEntity.builder() .bookingItem(givenBookingItem) .type(CLOUD_SERVER) @@ -363,17 +410,29 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu }).assertSuccessful().returnedValue(); } - HsBookingItemEntity givenBookingItem(final String debitorName, final String bookingItemCaption) { - final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike(debitorName).stream().findAny().orElseThrow(); - return bookingItemRepo.findAllByDebitorUuid(givenDebitor.getUuid()).stream() - .filter(i -> i.getCaption().equals(bookingItemCaption)) + HsBookingItemEntity givenBookingItem(final String projectCaption, final String bookingItemCaption) { + return bookingItemRepo.findByCaption(bookingItemCaption).stream() + .filter(i -> i.getRelatedProject().getCaption().equals(projectCaption)) .findAny().orElseThrow(); } - HsHostingAssetEntity givenManagedServer(final String debitorName, final HsHostingAssetType type) { - final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike(debitorName).stream().findAny().orElseThrow(); - return assetRepo.findAllByCriteria(givenDebitor.getUuid(), null, type).stream() + HsHostingAssetEntity givenHostingAsset(final String projectCaption, final HsHostingAssetType type) { + final var givenProject = projectRepo.findByCaption(projectCaption).stream() .findAny().orElseThrow(); + return assetRepo.findAllByCriteria(givenProject.getUuid(), null, type).stream() + .findAny().orElseThrow(); + } + + HsBookingItemEntity newBookingItem( + final HsBookingItemEntity parentBookingItem, + final HsBookingItemType type, + final String caption) { + final var newBookingItem = HsBookingItemEntity.builder() + .parentItem(parentBookingItem) + .type(type) + .caption(caption) + .build(); + return toCleanup(bookingItemRepo.save(newBookingItem)); } void exactlyTheseAssetsAreReturned( @@ -381,12 +440,8 @@ class HsHostingAssetRepositoryIntegrationTest extends ContextBasedTestWithCleanu final String... serverNames) { assertThat(actualResult) .extracting(HsHostingAssetEntity::toString) + .extracting(input -> input.replaceAll("\\s+", " ")) + .extracting(input -> input.replaceAll("\"", "")) .containsExactlyInAnyOrder(serverNames); } - - void allTheseServersAreReturned(final List actualResult, final String... serverNames) { - assertThat(actualResult) - .extracting(HsHostingAssetEntity::toString) - .contains(serverNames); - } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetTypeUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetTypeUnitTest.java new file mode 100644 index 00000000..870e2f8f --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetTypeUnitTest.java @@ -0,0 +1,214 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HsHostingAssetTypeUnitTest { + + @Test + void generatedPlantUML() { + final var result = HsHostingAssetType.renderAsEmbeddedPlantUml(); + + assertThat(result).isEqualTo(""" + ## HostingAsset Type Structure + + + ### Domain + + ```plantuml + @startuml + left to right direction + + package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + } + + package Hosting #feb28c{ + package Domain #99bcdb { + entity HA_DOMAIN_SETUP + entity HA_DOMAIN_DNS_SETUP + entity HA_DOMAIN_HTTP_SETUP + entity HA_DOMAIN_SMTP_SETUP + entity HA_DOMAIN_MBOX_SETUP + entity HA_EMAIL_ADDRESS + } + + package Server #99bcdb { + entity HA_CLOUD_SERVER + entity HA_MANAGED_SERVER + entity HA_IP_NUMBER + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + + } + + BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + + HA_CLOUD_SERVER *==> BI_CLOUD_SERVER + HA_MANAGED_SERVER *==> BI_MANAGED_SERVER + HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE + HA_MANAGED_WEBSPACE o..> HA_MANAGED_SERVER + HA_UNIX_USER *==> HA_MANAGED_WEBSPACE + HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE + HA_DOMAIN_SETUP o..> HA_DOMAIN_SETUP + HA_DOMAIN_DNS_SETUP *==> HA_DOMAIN_SETUP + HA_DOMAIN_DNS_SETUP o..> HA_MANAGED_WEBSPACE + HA_DOMAIN_HTTP_SETUP *==> HA_DOMAIN_SETUP + HA_DOMAIN_HTTP_SETUP o..> HA_UNIX_USER + HA_DOMAIN_SMTP_SETUP *==> HA_DOMAIN_SETUP + HA_DOMAIN_SMTP_SETUP o..> HA_MANAGED_WEBSPACE + HA_DOMAIN_MBOX_SETUP *==> HA_DOMAIN_SETUP + HA_DOMAIN_MBOX_SETUP o..> HA_MANAGED_WEBSPACE + HA_EMAIL_ADDRESS *==> HA_DOMAIN_MBOX_SETUP + HA_IP_NUMBER o..> HA_CLOUD_SERVER + HA_IP_NUMBER o..> HA_MANAGED_SERVER + HA_IP_NUMBER o..> HA_MANAGED_WEBSPACE + + package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 + } + Booking -down[hidden]->Legend + ``` + + ### MariaDB + + ```plantuml + @startuml + left to right direction + + package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + } + + package Hosting #feb28c{ + package MariaDB #99bcdb { + entity HA_MARIADB_INSTANCE + entity HA_MARIADB_USER + entity HA_MARIADB_DATABASE + } + + package Server #99bcdb { + entity HA_CLOUD_SERVER + entity HA_MANAGED_SERVER + entity HA_IP_NUMBER + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + + } + + BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + + HA_CLOUD_SERVER *==> BI_CLOUD_SERVER + HA_MANAGED_SERVER *==> BI_MANAGED_SERVER + HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE + HA_MANAGED_WEBSPACE o..> HA_MANAGED_SERVER + HA_UNIX_USER *==> HA_MANAGED_WEBSPACE + HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE + HA_MARIADB_INSTANCE *==> HA_MANAGED_SERVER + HA_MARIADB_USER *==> HA_MARIADB_INSTANCE + HA_MARIADB_USER o..> HA_MANAGED_WEBSPACE + HA_MARIADB_DATABASE *==> HA_MANAGED_WEBSPACE + HA_MARIADB_DATABASE o..> HA_MARIADB_INSTANCE + HA_IP_NUMBER o..> HA_CLOUD_SERVER + HA_IP_NUMBER o..> HA_MANAGED_SERVER + HA_IP_NUMBER o..> HA_MANAGED_WEBSPACE + + package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 + } + Booking -down[hidden]->Legend + ``` + + ### PostgreSQL + + ```plantuml + @startuml + left to right direction + + package Booking #feb28c { + entity BI_PRIVATE_CLOUD + entity BI_CLOUD_SERVER + entity BI_MANAGED_SERVER + entity BI_MANAGED_WEBSPACE + } + + package Hosting #feb28c{ + package PostgreSQL #99bcdb { + entity HA_PGSQL_INSTANCE + entity HA_PGSQL_USER + entity HA_PGSQL_DATABASE + } + + package Server #99bcdb { + entity HA_CLOUD_SERVER + entity HA_MANAGED_SERVER + entity HA_IP_NUMBER + } + + package Webspace #99bcdb { + entity HA_MANAGED_WEBSPACE + entity HA_UNIX_USER + entity HA_EMAIL_ALIAS + } + + } + + BI_CLOUD_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_SERVER *--> BI_PRIVATE_CLOUD + BI_MANAGED_WEBSPACE *--> BI_MANAGED_SERVER + + HA_CLOUD_SERVER *==> BI_CLOUD_SERVER + HA_MANAGED_SERVER *==> BI_MANAGED_SERVER + HA_MANAGED_WEBSPACE *==> BI_MANAGED_WEBSPACE + HA_MANAGED_WEBSPACE o..> HA_MANAGED_SERVER + HA_UNIX_USER *==> HA_MANAGED_WEBSPACE + HA_EMAIL_ALIAS *==> HA_MANAGED_WEBSPACE + HA_PGSQL_INSTANCE *==> HA_MANAGED_SERVER + HA_PGSQL_USER *==> HA_PGSQL_INSTANCE + HA_PGSQL_USER o..> HA_MANAGED_WEBSPACE + HA_PGSQL_DATABASE *==> HA_MANAGED_WEBSPACE + HA_PGSQL_DATABASE o..> HA_PGSQL_INSTANCE + HA_IP_NUMBER o..> HA_CLOUD_SERVER + HA_IP_NUMBER o..> HA_MANAGED_SERVER + HA_IP_NUMBER o..> HA_MANAGED_WEBSPACE + + package Legend #white { + SUB_ENTITY1 *--> REQUIRED_PARENT_ENTITY + SUB_ENTITY2 *..> OPTIONAL_PARENT_ENTITY + ASSIGNED_ENTITY1 o--> REQUIRED_ASSIGNED_TO_ENTITY1 + ASSIGNED_ENTITY2 o..> OPTIONAL_ASSIGNED_TO_ENTITY2 + } + Booking -down[hidden]->Legend + ``` + + This code generated was by HsHostingAssetType.main, do not amend manually. + """); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/TestHsHostingAssetEntities.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/TestHsHostingAssetEntities.java new file mode 100644 index 00000000..e409306b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/TestHsHostingAssetEntities.java @@ -0,0 +1,22 @@ +package net.hostsharing.hsadminng.hs.hosting.asset; + +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_MANAGED_SERVER_BOOKING_ITEM; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_MANAGED_WEBSPACE_BOOKING_ITEM; + +public class TestHsHostingAssetEntities { + + public static final HsHostingAssetEntity TEST_MANAGED_SERVER_HOSTING_ASSET = HsHostingAssetEntity.builder() + .type(HsHostingAssetType.MANAGED_SERVER) + .identifier("vm1234") + .caption("some managed server") + .bookingItem(TEST_MANAGED_SERVER_BOOKING_ITEM) + .build(); + + public static final HsHostingAssetEntity TEST_MANAGED_WEBSPACE_HOSTING_ASSET = HsHostingAssetEntity.builder() + .type(HsHostingAssetType.MANAGED_WEBSPACE) + .identifier("xyz00") + .caption("some managed webspace") + .bookingItem(TEST_MANAGED_WEBSPACE_BOOKING_ITEM) + .build(); + +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistryUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistryUnitTest.java new file mode 100644 index 00000000..c1c8a53c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HostingAssetEntityValidatorRegistryUnitTest.java @@ -0,0 +1,45 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +class HostingAssetEntityValidatorRegistryUnitTest { + + @Test + void forTypeWithUnknownTypeThrowsException() { + // when + final var thrown = catchThrowable(() -> { + HostingAssetEntityValidatorRegistry.forType(null); + }); + + // then + assertThat(thrown).hasMessage("no validator found for type null"); + } + + @Test + void typesReturnsAllImplementedTypes() { + // when + final var types = HostingAssetEntityValidatorRegistry.types(); + + // then + // TODO.test: when all types are implemented, replace with set of all types: + // assertThat(types).isEqualTo(EnumSet.allOf(HsHostingAssetType.class)); + // also remove "Implemented" from the test method name. + assertThat(types).containsExactlyInAnyOrder( + HsHostingAssetType.CLOUD_SERVER, + HsHostingAssetType.MANAGED_SERVER, + HsHostingAssetType.MANAGED_WEBSPACE, + HsHostingAssetType.UNIX_USER, + HsHostingAssetType.EMAIL_ALIAS, + HsHostingAssetType.DOMAIN_SETUP, + HsHostingAssetType.DOMAIN_DNS_SETUP, + HsHostingAssetType.DOMAIN_HTTP_SETUP, + HsHostingAssetType.DOMAIN_SMTP_SETUP, + HsHostingAssetType.DOMAIN_MBOX_SETUP, + HsHostingAssetType.EMAIL_ADDRESS + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidatorUnitTest.java index ee77c565..b7e3516a 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsCloudServerHostingAssetValidatorUnitTest.java @@ -1,13 +1,16 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; import org.junit.jupiter.api.Test; import java.util.Map; import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_CLOUD_SERVER_BOOKING_ITEM; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; -import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsHostingAssetEntityValidators.forType; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; import static org.assertj.core.api.Assertions.assertThat; class HsCloudServerHostingAssetValidatorUnitTest { @@ -17,39 +20,87 @@ class HsCloudServerHostingAssetValidatorUnitTest { // given final var cloudServerHostingAssetEntity = HsHostingAssetEntity.builder() .type(CLOUD_SERVER) + .identifier("vm1234") .config(Map.ofEntries( - entry("RAM", 2000), - entry("SSD", 256), - entry("Traffic", "250"), - entry("SLA-Platform", "xxx") + entry("RAM", 2000) )) .build(); - final var validator = forType(cloudServerHostingAssetEntity.getType()); + final var validator = HostingAssetEntityValidatorRegistry.forType(cloudServerHostingAssetEntity.getType()); // when - final var result = validator.validate(cloudServerHostingAssetEntity); + final var result = validator.validateEntity(cloudServerHostingAssetEntity); // then assertThat(result).containsExactlyInAnyOrder( - "'config.SLA-Platform' is not expected but is set to 'xxx'", - "'config.CPUs' is required but missing", - "'config.RAM' is expected to be <= 128 but is 2000", - "'config.SSD' is expected to be multiple of 25 but is 256", - "'config.Traffic' is expected to be of type class java.lang.Integer, but is of type 'String'"); + "'CLOUD_SERVER:vm1234.bookingItem' must be of type CLOUD_SERVER but is null", + "'CLOUD_SERVER:vm1234.config.RAM' is not expected but is set to '2000'"); + } + + @Test + void validatesInvalidIdentifier() { + // given + final var cloudServerHostingAssetEntity = HsHostingAssetEntity.builder() + .type(CLOUD_SERVER) + .identifier("xyz99") + .bookingItem(TEST_CLOUD_SERVER_BOOKING_ITEM) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(cloudServerHostingAssetEntity.getType()); + + + // when + final var result = validator.validateEntity(cloudServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'identifier' expected to match '^vm[0-9][0-9][0-9][0-9]$', but is 'xyz99'"); } @Test void containsAllValidations() { // when - final var validator = forType(CLOUD_SERVER); + final var validator = HostingAssetEntityValidatorRegistry.forType(CLOUD_SERVER); // then - assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( - "{type=integer, propertyName=CPUs, required=true, unit=null, min=1, max=32, step=null}", - "{type=integer, propertyName=RAM, required=true, unit=GB, min=1, max=128, step=null}", - "{type=integer, propertyName=SSD, required=true, unit=GB, min=25, max=1000, step=25}", - "{type=integer, propertyName=HDD, required=false, unit=GB, min=0, max=4000, step=250}", - "{type=integer, propertyName=Traffic, required=true, unit=GB, min=250, max=10000, step=250}"); + assertThat(validator.properties()).map(Map::toString).isEmpty(); + } + + @Test + void validatesBookingItemType() { + // given + final var mangedServerHostingAssetEntity = HsHostingAssetEntity.builder() + .type(MANAGED_SERVER) + .identifier("xyz00") + .bookingItem(HsBookingItemEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'MANAGED_SERVER:xyz00.bookingItem' must be of type MANAGED_SERVER but is of type CLOUD_SERVER"); + } + + @Test + void rejectsInvalidReferencedEntities() { + // given + final var mangedServerHostingAssetEntity = HsHostingAssetEntity.builder() + .type(CLOUD_SERVER) + .identifier("vm1234") + .bookingItem(HsBookingItemEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .parentAsset(HsHostingAssetEntity.builder().type(MANAGED_SERVER).build()) + .assignedToAsset(HsHostingAssetEntity.builder().type(CLOUD_SERVER).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'CLOUD_SERVER:vm1234.parentAsset' must be null but is of type MANAGED_SERVER", + "'CLOUD_SERVER:vm1234.assignedToAsset' must be null but is of type CLOUD_SERVER"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..7f66379c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainDnsSetupHostingAssetValidatorUnitTest.java @@ -0,0 +1,248 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity.HsHostingAssetEntityBuilder; +import net.hostsharing.hsadminng.mapper.Array; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_DNS_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.TestHsHostingAssetEntities.TEST_MANAGED_WEBSPACE_HOSTING_ASSET; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator.RR_COMMENT; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator.RR_RECORD_DATA; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator.RR_RECORD_TYPE; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator.RR_REGEX_IN; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator.RR_REGEX_NAME; +import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainDnsSetupHostingAssetValidator.RR_REGEX_TTL; +import static org.assertj.core.api.Assertions.assertThat; + +class HsDomainDnsSetupHostingAssetValidatorUnitTest { + + static final HsHostingAssetEntity validDomainSetupEntity = HsHostingAssetEntity.builder() + .type(DOMAIN_SETUP) + .identifier("example.org") + .build(); + + static HsHostingAssetEntityBuilder validEntityBuilder() { + return HsHostingAssetEntity.builder() + .type(DOMAIN_DNS_SETUP) + .parentAsset(validDomainSetupEntity) + .assignedToAsset(TEST_MANAGED_WEBSPACE_HOSTING_ASSET) + .identifier("example.org|DNS") + .config(Map.ofEntries( + entry("user-RR", Array.of( + "@ 1814400 IN XXX example.org. root.example.org ( 1234 10800 900 604800 86400 )", + "www IN CNAME example.com. ; www.example.com is an alias for example.com", + "test1 IN 1h30m CNAME example.com.", + "test2 1h30m IN CNAME example.com.", + "ns IN A 192.0.2.2; IPv4 address for ns.example.com") + ) + )); + } + + @Test + void containsExpectedProperties() { + // when + final var validator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_DNS_SETUP); + + // then + assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( + "{type=integer, propertyName=TTL, min=0, defaultValue=21600}", + "{type=boolean, propertyName=auto-SOA-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-NS-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-MX-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-A-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-AAAA-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-MAILSERVICES-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-AUTOCONFIG-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-AUTODISCOVER-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-DKIM-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-SPF-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-WILDCARD-MX-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-WILDCARD-A-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-WILDCARD-AAAA-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-WILDCARD-DKIM-RR, defaultValue=true}", + "{type=boolean, propertyName=auto-WILDCARD-SPF-RR, defaultValue=true}", + "{type=string[], propertyName=user-RR, elementsOf={type=string, propertyName=user-RR, matchesRegEx=[([a-z0-9\\.-]+|@)\\s+(([1-9][0-9]*[mMhHdDwW]{0,1})+\\s+)*IN\\s+[A-Z]+\\s+[^;].*(;.*)*, ([a-z0-9\\.-]+|@)\\s+IN\\s+(([1-9][0-9]*[mMhHdDwW]{0,1})+\\s+)*[A-Z]+\\s+[^;].*(;.*)*], required=true}}" + ); + } + + @Test + void preprocessesTakesIdentifierFromParent() { + // given + final var givenEntity = validEntityBuilder().build(); + assertThat(givenEntity.getParentAsset().getIdentifier()).as("preconditon failed").isEqualTo("example.org"); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + validator.preprocessEntity(givenEntity); + + // then + assertThat(givenEntity.getIdentifier()).isEqualTo("example.org|DNS"); + } + + @Test + void rejectsInvalidIdentifier() { + // given + final var givenEntity = validEntityBuilder().identifier("example.org").build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).containsExactly( + "'identifier' expected to match '^\\Qexample.org|DNS\\E$', but is 'example.org'" + ); + } + + @Test + void acceptsValidIdentifier() { + // given + final var givenEntity = validEntityBuilder().identifier(validDomainSetupEntity.getIdentifier()+"|DNS").build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var result = validator.validateEntity(givenEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void rejectsInvalidReferencedEntities() { + // given + final var mangedServerHostingAssetEntity = validEntityBuilder() + .bookingItem(HsBookingItemEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build()) + .parentAsset(null) + .assignedToAsset(HsHostingAssetEntity.builder().type(DOMAIN_SETUP).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'DOMAIN_DNS_SETUP:example.org|DNS.bookingItem' must be null but is of type CLOUD_SERVER", + "'DOMAIN_DNS_SETUP:example.org|DNS.parentAsset' must be of type DOMAIN_SETUP but is null", + "'DOMAIN_DNS_SETUP:example.org|DNS.assignedToAsset' must be of type MANAGED_WEBSPACE but is of type DOMAIN_SETUP"); + } + + @Test + void acceptsValidEntity() { + // given + final var givenEntity = validEntityBuilder().build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var errors = validator.validateEntity(givenEntity); + + // then + assertThat(errors).isEmpty(); + } + + @Test + void rejectsInvalidProperties() { + // given + final var mangedServerHostingAssetEntity = validEntityBuilder() + .config(Map.ofEntries( + entry("TTL", "1d30m"), // currently only an integer for seconds is implemented here + entry("user-RR", Array.of( + "@ 1814400 IN 1814400 BAD1 TTL only allowed once", + "www BAD1 Record-Class missing / not enough columns")) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'DOMAIN_DNS_SETUP:example.org|DNS.config.TTL' is expected to be of type Integer, but is of type String", + "'DOMAIN_DNS_SETUP:example.org|DNS.config.user-RR' is expected to match any of [([a-z0-9\\.-]+|@)\\s+(([1-9][0-9]*[mMhHdDwW]{0,1})+\\s+)*IN\\s+[A-Z]+\\s+[^;].*(;.*)*, ([a-z0-9\\.-]+|@)\\s+IN\\s+(([1-9][0-9]*[mMhHdDwW]{0,1})+\\s+)*[A-Z]+\\s+[^;].*(;.*)*] but '@ 1814400 IN 1814400 BAD1 TTL only allowed once' does not match any", + "'DOMAIN_DNS_SETUP:example.org|DNS.config.user-RR' is expected to match any of [([a-z0-9\\.-]+|@)\\s+(([1-9][0-9]*[mMhHdDwW]{0,1})+\\s+)*IN\\s+[A-Z]+\\s+[^;].*(;.*)*, ([a-z0-9\\.-]+|@)\\s+IN\\s+(([1-9][0-9]*[mMhHdDwW]{0,1})+\\s+)*[A-Z]+\\s+[^;].*(;.*)*] but 'www BAD1 Record-Class missing / not enough columns' does not match any"); + } + + @Test + void validStringMatchesRegEx() { + assertThat("@ ").matches(RR_REGEX_NAME); + assertThat("ns ").matches(RR_REGEX_NAME); + assertThat("example.com. ").matches(RR_REGEX_NAME); + + assertThat("12400 ").matches(RR_REGEX_TTL); + assertThat("12400\t\t ").matches(RR_REGEX_TTL); + assertThat("12400 \t\t").matches(RR_REGEX_TTL); + assertThat("1h30m ").matches(RR_REGEX_TTL); + assertThat("30m ").matches(RR_REGEX_TTL); + + assertThat("IN ").matches(RR_REGEX_IN); + assertThat("IN\t\t ").matches(RR_REGEX_IN); + assertThat("IN \t\t").matches(RR_REGEX_IN); + + assertThat("CNAME ").matches(RR_RECORD_TYPE); + assertThat("CNAME\t\t ").matches(RR_RECORD_TYPE); + assertThat("CNAME \t\t").matches(RR_RECORD_TYPE); + + assertThat("example.com.").matches(RR_RECORD_DATA); + assertThat("123.123.123.123").matches(RR_RECORD_DATA); + assertThat("(some more complex argument in parenthesis)").matches(RR_RECORD_DATA); + assertThat("\"some more complex argument; including a semicolon\"").matches(RR_RECORD_DATA); + + assertThat("; whatever ; \" really anything").matches(RR_COMMENT); + } + + @Test + void generatesZonefile() { + // given + final var givenEntity = validEntityBuilder().build(); + final var validator = (HsDomainDnsSetupHostingAssetValidator) HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var zonefile = validator.toZonefileString(givenEntity); + + // then + assertThat(zonefile).isEqualTo(""" + $ORIGIN example.org. + $TTL 21600 + + ; these records are just placeholders to create a valid zonefile for the validation + @ 1814400 IN SOA example.org. root.example.org ( 1999010100 10800 900 604800 86400 ) + @ IN NS ns + + @ 1814400 IN XXX example.org. root.example.org ( 1234 10800 900 604800 86400 ) + www IN CNAME example.com. ; www.example.com is an alias for example.com + test1 IN 1h30m CNAME example.com. + test2 1h30m IN CNAME example.com. + ns IN A 192.0.2.2; IPv4 address for ns.example.com + """); + } + + @Test + void rejectsInvalidZonefile() { + // given + final var givenEntity = validEntityBuilder().config(Map.ofEntries( + entry("user-RR", Array.of( + "example.org. 1814400 IN SOA example.org. root.example.org (1234 10800 900 604800 86400)" + )) + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType()); + + // when + final var errors = validator.validateContext(givenEntity); + + // then + assertThat(errors).containsExactlyInAnyOrder( + "dns_master_load: example.org: multiple RRs of singleton type", + "zone example.org/IN: loading from master file (null) failed: multiple RRs of singleton type", + "zone example.org/IN: not loaded due to errors." + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..29b4c05b --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java @@ -0,0 +1,163 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity.HsHostingAssetEntityBuilder; +import net.hostsharing.hsadminng.mapper.Array; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_HTTP_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER; +import static org.assertj.core.api.Assertions.assertThat; + +class HsDomainHttpSetupHostingAssetValidatorUnitTest { + + static final HsHostingAssetEntity validDomainSetupEntity = HsHostingAssetEntity.builder() + .type(DOMAIN_SETUP) + .identifier("example.org") + .build(); + + static HsHostingAssetEntityBuilder validEntityBuilder() { + return HsHostingAssetEntity.builder() + .type(DOMAIN_HTTP_SETUP) + .parentAsset(validDomainSetupEntity) + .assignedToAsset(HsHostingAssetEntity.builder().type(UNIX_USER).build()) + .identifier("example.org|HTTP") + .config(Map.ofEntries( + entry("passenger-errorpage", true), + entry("subdomains", Array.of("www", "test") + ) + )); + } + + @Test + void containsExpectedProperties() { + // when + final var validator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_HTTP_SETUP); + + // then + assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder( + "{type=boolean, propertyName=htdocsfallback, defaultValue=true}", + "{type=boolean, propertyName=indexes, defaultValue=true}", + "{type=boolean, propertyName=cgi, defaultValue=true}", + "{type=boolean, propertyName=passenger, defaultValue=true}", + "{type=boolean, propertyName=passenger-errorpage}", + "{type=boolean, propertyName=fastcgi, defaultValue=true}", + "{type=boolean, propertyName=autoconfig, defaultValue=true}", + "{type=boolean, propertyName=greylisting, defaultValue=true}", + "{type=boolean, propertyName=includes, defaultValue=true}", + "{type=boolean, propertyName=letsencrypt, defaultValue=true}", + "{type=boolean, propertyName=multiviews, defaultValue=true}", + "{type=string, propertyName=fcgi-php-bin, matchesRegEx=[^/], provided=[/usr/lib/cgi-bin/php], defaultValue=/usr/lib/cgi-bin/php}", + "{type=string, propertyName=passenger-nodejs, matchesRegEx=[^/], provided=[/usr/bin/node], defaultValue=/usr/bin/node}", + "{type=string, propertyName=passenger-python, matchesRegEx=[^/], provided=[/usr/bin/python3], defaultValue=/usr/bin/python3}", + "{type=string, propertyName=passenger-ruby, matchesRegEx=[^/], provided=[/usr/bin/ruby], defaultValue=/usr/bin/ruby}", + "{type=string[], propertyName=subdomains, elementsOf={type=string, propertyName=subdomains, matchesRegEx=[(?!-)[A-Za-z0-9-]{1,63}(? valid(cloudServerHostingAssetEntity) ); - - // then - assertThat(result).isInstanceOf(ValidationException.class) - .hasMessageContaining( - "'config.CPUs' is required but missing", - "'config.RAM' is required but missing", - "'config.SSD' is required but missing", - "'config.Traffic' is required but missing"); - } -} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidatorUnitTest.java index a9ee1433..67ff4db5 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedServerHostingAssetValidatorUnitTest.java @@ -1,13 +1,17 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; import org.junit.jupiter.api.Test; import java.util.Map; import static java.util.Map.entry; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_CLOUD_SERVER_BOOKING_ITEM; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_MANAGED_SERVER_BOOKING_ITEM; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; -import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsHostingAssetEntityValidators.forType; import static org.assertj.core.api.Assertions.assertThat; class HsManagedServerHostingAssetValidatorUnitTest { @@ -17,24 +21,67 @@ class HsManagedServerHostingAssetValidatorUnitTest { // given final var mangedWebspaceHostingAssetEntity = HsHostingAssetEntity.builder() .type(MANAGED_SERVER) + .identifier("vm1234") + .bookingItem(TEST_MANAGED_SERVER_BOOKING_ITEM) + .parentAsset(HsHostingAssetEntity.builder().type(CLOUD_SERVER).build()) + .assignedToAsset(HsHostingAssetEntity.builder().type(CLOUD_SERVER).build()) .config(Map.ofEntries( - entry("RAM", 2000), - entry("SSD", 256), - entry("Traffic", "250"), - entry("SLA-Platform", "xxx") + entry("monit_max_hdd_usage", "90"), + entry("monit_max_cpu_usage", 2), + entry("monit_max_ram_usage", 101) )) .build(); - final var validator = forType(mangedWebspaceHostingAssetEntity.getType()); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedWebspaceHostingAssetEntity.getType()); // when - final var result = validator.validate(mangedWebspaceHostingAssetEntity); + final var result = validator.validateEntity(mangedWebspaceHostingAssetEntity); // then assertThat(result).containsExactlyInAnyOrder( - "'config.SLA-Platform' is not expected but is set to 'xxx'", - "'config.CPUs' is required but missing", - "'config.RAM' is expected to be <= 128 but is 2000", - "'config.SSD' is expected to be multiple of 25 but is 256", - "'config.Traffic' is expected to be of type class java.lang.Integer, but is of type 'String'"); + "'MANAGED_SERVER:vm1234.parentAsset' must be null but is of type CLOUD_SERVER", + "'MANAGED_SERVER:vm1234.assignedToAsset' must be null but is of type CLOUD_SERVER", + "'MANAGED_SERVER:vm1234.config.monit_max_cpu_usage' is expected to be at least 10 but is 2", + "'MANAGED_SERVER:vm1234.config.monit_max_ram_usage' is expected to be at most 100 but is 101", + "'MANAGED_SERVER:vm1234.config.monit_max_hdd_usage' is expected to be of type Integer, but is of type String"); + } + + @Test + void validatesInvalidIdentifier() { + // given + final var mangedServerHostingAssetEntity = HsHostingAssetEntity.builder() + .type(MANAGED_SERVER) + .identifier("xyz00") + .bookingItem(HsBookingItemEntity.builder().type(HsBookingItemType.MANAGED_SERVER).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'identifier' expected to match '^vm[0-9][0-9][0-9][0-9]$', but is 'xyz00'"); + } + + @Test + void rejectsInvalidReferencedEntities() { + // given + final var mangedServerHostingAssetEntity = HsHostingAssetEntity.builder() + .type(MANAGED_SERVER) + .identifier("xyz00") + .bookingItem(TEST_CLOUD_SERVER_BOOKING_ITEM) + .parentAsset(HsHostingAssetEntity.builder().type(CLOUD_SERVER).build()) + .assignedToAsset(HsHostingAssetEntity.builder().type(MANAGED_SERVER).build()) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(mangedServerHostingAssetEntity.getType()); + + // when + final var result = validator.validateEntity(mangedServerHostingAssetEntity); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'MANAGED_SERVER:xyz00.bookingItem' must be of type MANAGED_SERVER but is of type CLOUD_SERVER", + "'MANAGED_SERVER:xyz00.parentAsset' must be null but is of type CLOUD_SERVER", + "'MANAGED_SERVER:xyz00.assignedToAsset' must be null but is of type MANAGED_SERVER"); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidatorUnitTest.java index e0397036..d7efcda4 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidatorUnitTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsManagedWebspaceHostingAssetValidatorUnitTest.java @@ -1,120 +1,170 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators; import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemEntity; +import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType; import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; -import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; import org.junit.jupiter.api.Test; import java.util.Map; +import java.util.stream.Stream; -import static java.util.Collections.emptyMap; import static java.util.Map.entry; -import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER; import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; import static org.assertj.core.api.Assertions.assertThat; +import static net.hostsharing.hsadminng.hs.booking.project.TestHsBookingProject.TEST_PROJECT; class HsManagedWebspaceHostingAssetValidatorUnitTest { final HsBookingItemEntity managedServerBookingItem = HsBookingItemEntity.builder() - .debitor(HsOfficeDebitorEntity.builder().defaultPrefix("abc").build() - ) + .project(TEST_PROJECT) + .type(HsBookingItemType.MANAGED_SERVER) + .caption("Test Managed-Server") + .resources(Map.ofEntries( + entry("CPUs", 2), + entry("RAM", 25), + entry("SSD", 25), + entry("Traffic", 250), + entry("SLA-Platform", "EXT4H"), + entry("SLA-EMail", true) + )) .build(); + final HsBookingItemEntity cloudServerBookingItem = managedServerBookingItem.toBuilder() + .type(HsBookingItemType.CLOUD_SERVER) + .caption("Test Cloud-Server") + .build(); + final HsHostingAssetEntity mangedServerAssetEntity = HsHostingAssetEntity.builder() - .type(MANAGED_SERVER) + .type(HsHostingAssetType.MANAGED_SERVER) .bookingItem(managedServerBookingItem) + .identifier("vm1234") .config(Map.ofEntries( - entry("HDD", 0), - entry("SSD", 1), - entry("Traffic", 10) + entry("monit_max_ssd_usage", 70), + entry("monit_max_cpu_usage", 80), + entry("monit_max_ram_usage", 90) + )) + .build(); + final HsHostingAssetEntity cloudServerAssetEntity = HsHostingAssetEntity.builder() + .type(HsHostingAssetType.CLOUD_SERVER) + .bookingItem(cloudServerBookingItem) + .identifier("vm1234") + .config(Map.ofEntries( + entry("monit_max_ssd_usage", 70), + entry("monit_max_cpu_usage", 80), + entry("monit_max_ram_usage", 90) )) .build(); @Test - void validatesIdentifier() { + void acceptsAlienIdentifierPrefixForPreExistingEntity() { // given - final var validator = HsHostingAssetEntityValidators.forType(MANAGED_WEBSPACE); + final var validator = HostingAssetEntityValidatorRegistry.forType(MANAGED_WEBSPACE); final var mangedWebspaceHostingAssetEntity = HsHostingAssetEntity.builder() .type(MANAGED_WEBSPACE) + .bookingItem(HsBookingItemEntity.builder() + .type(HsBookingItemType.MANAGED_WEBSPACE) + .resources(Map.ofEntries(entry("SSD", 25), entry("Traffic", 250))) + .build()) .parentAsset(mangedServerAssetEntity) .identifier("xyz00") - .config(Map.ofEntries( - entry("HDD", 0), - entry("SSD", 1), - entry("Traffic", 10) - )) + .isLoaded(true) .build(); // when - final var result = validator.validate(mangedWebspaceHostingAssetEntity); + final var result = validator.validateContext(mangedWebspaceHostingAssetEntity); + + // then + assertThat(result).isEmpty(); + } + + @Test + void validatesIdentifierAndReferencedEntities() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(MANAGED_WEBSPACE); + final var mangedWebspaceHostingAssetEntity = HsHostingAssetEntity.builder() + .type(MANAGED_WEBSPACE) + .bookingItem(HsBookingItemEntity.builder().type(HsBookingItemType.MANAGED_WEBSPACE).build()) + .parentAsset(mangedServerAssetEntity) + .identifier("xyz00") + .build(); + + // when + final var result = validator.validateEntity(mangedWebspaceHostingAssetEntity); // then assertThat(result).containsExactly("'identifier' expected to match '^abc[0-9][0-9]$', but is 'xyz00'"); } - - @Test - void validatesMissingProperties() { - // given - final var validator = HsHostingAssetEntityValidators.forType(MANAGED_WEBSPACE); - final var mangedWebspaceHostingAssetEntity = HsHostingAssetEntity.builder() - .type(MANAGED_WEBSPACE) - .parentAsset(mangedServerAssetEntity) - .identifier("abc00") - .config(emptyMap()) - .build(); - - // when - final var result = validator.validate(mangedWebspaceHostingAssetEntity); - - // then - assertThat(result).containsExactlyInAnyOrder( - "'config.SSD' is required but missing", - "'config.Traffic' is required but missing" - ); - } - @Test void validatesUnknownProperties() { // given - final var validator = HsHostingAssetEntityValidators.forType(MANAGED_WEBSPACE); + final var validator = HostingAssetEntityValidatorRegistry.forType(MANAGED_WEBSPACE); final var mangedWebspaceHostingAssetEntity = HsHostingAssetEntity.builder() .type(MANAGED_WEBSPACE) + .bookingItem(HsBookingItemEntity.builder().type(HsBookingItemType.MANAGED_WEBSPACE).build()) .parentAsset(mangedServerAssetEntity) .identifier("abc00") .config(Map.ofEntries( - entry("HDD", 0), - entry("SSD", 1), - entry("Traffic", 10), entry("unknown", "some value") )) .build(); // when - final var result = validator.validate(mangedWebspaceHostingAssetEntity); + final var result = validator.validateEntity(mangedWebspaceHostingAssetEntity); // then - assertThat(result).containsExactly("'config.unknown' is not expected but is set to 'some value'"); + assertThat(result).containsExactly("'MANAGED_WEBSPACE:abc00.config.unknown' is not expected but is set to 'some value'"); } @Test - void validatesValidProperties() { + void validatesValidEntity() { // given - final var validator = HsHostingAssetEntityValidators.forType(MANAGED_WEBSPACE); + final var validator = HostingAssetEntityValidatorRegistry.forType(MANAGED_WEBSPACE); final var mangedWebspaceHostingAssetEntity = HsHostingAssetEntity.builder() .type(MANAGED_WEBSPACE) + .bookingItem(HsBookingItemEntity.builder() + .type(HsBookingItemType.MANAGED_WEBSPACE) + .caption("some ManagedWebspace") + .resources(Map.ofEntries(entry("SSD", 25), entry("Traffic", 250))) + .build()) .parentAsset(mangedServerAssetEntity) .identifier("abc00") - .config(Map.ofEntries( - entry("HDD", 200), - entry("SSD", 25), - entry("Traffic", 250) - )) .build(); // when - final var result = validator.validate(mangedWebspaceHostingAssetEntity); + final var result = Stream.concat( + validator.validateEntity(mangedWebspaceHostingAssetEntity).stream(), + validator.validateContext(mangedWebspaceHostingAssetEntity).stream()) + .toList(); // then assertThat(result).isEmpty(); } + + @Test + void rejectsInvalidEntityReferences() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(MANAGED_WEBSPACE); + final var mangedWebspaceHostingAssetEntity = HsHostingAssetEntity.builder() + .type(MANAGED_WEBSPACE) + .bookingItem(HsBookingItemEntity.builder() + .type(HsBookingItemType.MANAGED_SERVER) + .caption("some ManagedServer") + .resources(Map.ofEntries(entry("SSD", 25), entry("Traffic", 250))) + .build()) + .parentAsset(cloudServerAssetEntity) + .assignedToAsset(HsHostingAssetEntity.builder().type(CLOUD_SERVER).build()) + .identifier("abc00") + .build(); + + // when + final var result = validator.validateEntity(mangedWebspaceHostingAssetEntity); + + // then + assertThat(result).containsExactly( + "'MANAGED_WEBSPACE:abc00.bookingItem' must be of type MANAGED_WEBSPACE but is of type MANAGED_SERVER", + "'MANAGED_WEBSPACE:abc00.parentAsset' must be null or of type MANAGED_SERVER but is of type CLOUD_SERVER", + "'MANAGED_WEBSPACE:abc00.assignedToAsset' must be null but is of type CLOUD_SERVER"); + } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java new file mode 100644 index 00000000..9a17eb27 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java @@ -0,0 +1,175 @@ +package net.hostsharing.hsadminng.hs.hosting.asset.validators; + +import net.hostsharing.hsadminng.hash.LinuxEtcShadowHashGenerator; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetEntity; +import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.stream.Stream; + +import static java.util.Map.ofEntries; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_MANAGED_SERVER_BOOKING_ITEM; +import static net.hostsharing.hsadminng.hs.booking.item.TestHsBookingItem.TEST_MANAGED_WEBSPACE_BOOKING_ITEM; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_WEBSPACE; +import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.UNIX_USER; +import static net.hostsharing.hsadminng.mapper.PatchMap.entry; +import static org.assertj.core.api.Assertions.assertThat; + +class HsUnixUserHostingAssetValidatorUnitTest { + + private final HsHostingAssetEntity TEST_MANAGED_SERVER_HOSTING_ASSET = HsHostingAssetEntity.builder() + .type(HsHostingAssetType.MANAGED_SERVER) + .identifier("vm1234") + .caption("some managed server") + .bookingItem(TEST_MANAGED_SERVER_BOOKING_ITEM) + .build(); + private final HsHostingAssetEntity TEST_MANAGED_WEBSPACE_HOSTING_ASSET = HsHostingAssetEntity.builder() + .type(MANAGED_WEBSPACE) + .bookingItem(TEST_MANAGED_WEBSPACE_BOOKING_ITEM) + .parentAsset(TEST_MANAGED_SERVER_HOSTING_ASSET) + .identifier("abc00") + .build(); + private final HsHostingAssetEntity GIVEN_VALID_UNIX_USER_HOSTING_ASSET = HsHostingAssetEntity.builder() + .type(UNIX_USER) + .parentAsset(TEST_MANAGED_WEBSPACE_HOSTING_ASSET) + .identifier("abc00-temp") + .caption("some valid test UnixUser") + .config(new HashMap<>(ofEntries( + entry("SSD hard quota", 50), + entry("SSD soft quota", 40), + entry("totpKey", "0x123456789abcdef01234"), + entry("password", "Hallo Computer, lass mich rein!") + ))) + .build(); + + @Test + void preparesUnixUser() { + // given + final var unixUserHostingAsset = GIVEN_VALID_UNIX_USER_HOSTING_ASSET; + final var validator = HostingAssetEntityValidatorRegistry.forType(unixUserHostingAsset.getType()); + + // when + LinuxEtcShadowHashGenerator.nextSalt("Ly3LbsArtL5u4EVt"); + validator.prepareProperties(unixUserHostingAsset); + + // then + assertThat(unixUserHostingAsset.getConfig()).containsExactlyInAnyOrderEntriesOf(ofEntries( + entry("SSD hard quota", 50), + entry("SSD soft quota", 40), + entry("totpKey", "0x123456789abcdef01234"), + entry("password", "$6$Ly3LbsArtL5u4EVt$i/ayIEvm0y4bjkFB6wbg8imbRIaw4mAA4gqYRVyoSkj.iIxJKS3KiRkSjP8gweNcpKL0Q0N31EadT8fCnWErL.") + )); + } + + @Test + void validatesValidUnixUser() { + // given + final var unixUserHostingAsset = GIVEN_VALID_UNIX_USER_HOSTING_ASSET; + final var validator = HostingAssetEntityValidatorRegistry.forType(unixUserHostingAsset.getType()); + + // when + final var result = Stream.concat( + validator.validateEntity(unixUserHostingAsset).stream(), + validator.validateContext(unixUserHostingAsset).stream() + ).toList(); + + // then + assertThat(result).isEmpty(); + } + + @Test + void validatesUnixUserProperties() { + // given + final var unixUserHostingAsset = HsHostingAssetEntity.builder() + .type(UNIX_USER) + .parentAsset(TEST_MANAGED_WEBSPACE_HOSTING_ASSET) + .identifier("abc00-temp") + .caption("some test UnixUser with invalid properties") + .config(ofEntries( + entry("SSD hard quota", 100), + entry("SSD soft quota", 200), + entry("HDD hard quota", 100), + entry("HDD soft quota", 200), + entry("shell", "/is/invalid"), + entry("homedir", "/is/read-only"), + entry("totpKey", "should be a hex number"), + entry("password", "short") + )) + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(unixUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(unixUserHostingAsset); + + // then + assertThat(result).containsExactlyInAnyOrder( + "'UNIX_USER:abc00-temp.config.SSD hard quota' is expected to be at most 50 but is 100", + "'UNIX_USER:abc00-temp.config.SSD soft quota' is expected to be at most 100 but is 200", + "'UNIX_USER:abc00-temp.config.HDD hard quota' is expected to be at most 0 but is 100", + "'UNIX_USER:abc00-temp.config.HDD soft quota' is expected to be at most 100 but is 200", + "'UNIX_USER:abc00-temp.config.shell' is expected to be one of [/bin/false, /bin/bash, /bin/csh, /bin/dash, /usr/bin/tcsh, /usr/bin/zsh, /usr/bin/passwd] but is '/is/invalid'", + "'UNIX_USER:abc00-temp.config.homedir' is readonly but given as '/is/read-only'", + "'UNIX_USER:abc00-temp.config.totpKey' is expected to match any of [^0x([0-9A-Fa-f]{2})+$] but provided value does not match", + "'UNIX_USER:abc00-temp.config.password' length is expected to be at min 8 but length of provided value is 5", + "'UNIX_USER:abc00-temp.config.password' must contain at least one character of at least 3 of the following groups: upper case letters, lower case letters, digits, special characters" + ); + } + + @Test + void validatesInvalidIdentifier() { + // given + final var unixUserHostingAsset = HsHostingAssetEntity.builder() + .type(UNIX_USER) + .parentAsset(HsHostingAssetEntity.builder().type(MANAGED_WEBSPACE).identifier("abc00").build()) + .identifier("xyz99-temp") + .build(); + final var validator = HostingAssetEntityValidatorRegistry.forType(unixUserHostingAsset.getType()); + + // when + final var result = validator.validateEntity(unixUserHostingAsset); + + // then + assertThat(result).containsExactly( + "'identifier' expected to match '^abc00$|^abc00-[a-z0-9]+$', but is 'xyz99-temp'"); + } + + @Test + void revampsUnixUser() { + // given + final var unixUserHostingAsset = GIVEN_VALID_UNIX_USER_HOSTING_ASSET; + final var validator = HostingAssetEntityValidatorRegistry.forType(unixUserHostingAsset.getType()); + + // when + LinuxEtcShadowHashGenerator.nextSalt("Ly3LbsArtL5u4EVt"); + final var result = validator.revampProperties(unixUserHostingAsset, unixUserHostingAsset.getConfig()); + + // then + assertThat(result).containsExactlyInAnyOrderEntriesOf(ofEntries( + entry("SSD hard quota", 50), + entry("SSD soft quota", 40), + entry("homedir", "/home/pacs/abc00/users/temp") + )); + } + + @Test + void describesItsProperties() { + // given + final var validator = HostingAssetEntityValidatorRegistry.forType(UNIX_USER); + + // when + final var props = validator.properties(); + + // then + assertThat(props).extracting(Object::toString).containsExactlyInAnyOrder( + "{type=integer, propertyName=SSD hard quota, unit=GB, maxFrom=SSD}", + "{type=integer, propertyName=SSD soft quota, unit=GB, maxFrom=SSD hard quota}", + "{type=integer, propertyName=HDD hard quota, unit=GB, maxFrom=HDD}", + "{type=integer, propertyName=HDD soft quota, unit=GB, maxFrom=HDD hard quota}", + "{type=enumeration, propertyName=shell, values=[/bin/false, /bin/bash, /bin/csh, /bin/dash, /usr/bin/tcsh, /usr/bin/zsh, /usr/bin/passwd], defaultValue=/bin/false}", + "{type=string, propertyName=homedir, readOnly=true, computed=true}", + "{type=string, propertyName=totpKey, matchesRegEx=[^0x([0-9A-Fa-f]{2})+$], minLength=20, maxLength=256, writeOnly=true, undisclosed=true}", + "{type=password, propertyName=password, minLength=8, maxLength=40, writeOnly=true, computed=true, hashedUsing=SHA512, undisclosed=true}" + ); + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepositoryIntegrationTest.java index c46210c4..d7d07f69 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/bankaccount/HsOfficeBankAccountRepositoryIntegrationTest.java @@ -4,7 +4,7 @@ import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.Nested; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRepositoryIntegrationTest.java index cca5c48c..4e591973 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/contact/HsOfficeContactRepositoryIntegrationTest.java @@ -4,7 +4,7 @@ import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.Nested; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java index ff6c9315..0c9215f9 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionRepositoryIntegrationTest.java @@ -5,7 +5,7 @@ import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipReposito import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionRepositoryIntegrationTest.java index 65f85b58..e6163cd4 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/coopshares/HsOfficeCoopSharesTransactionRepositoryIntegrationTest.java @@ -5,7 +5,7 @@ import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipReposito import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java index 27f9f2c8..1408a87d 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorControllerAcceptanceTest.java @@ -745,7 +745,7 @@ class HsOfficeDebitorControllerAcceptanceTest extends ContextBasedTestWithCleanu jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net"); final var count = em.createQuery( - "DELETE FROM HsOfficeDebitorEntity d WHERE d.debitorNumberSuffix >= " + LOWEST_TEMP_DEBITOR_SUFFIX) + "DELETE FROM HsBookingDebitorEntity d WHERE d.debitorNumberSuffix >= " + LOWEST_TEMP_DEBITOR_SUFFIX) .executeUpdate(); System.out.printf("deleted %d entities%n", count); }); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java index c234a680..dc1b3f61 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/HsOfficeDebitorRepositoryIntegrationTest.java @@ -11,7 +11,7 @@ import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantsDiagramService; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.hibernate.Hibernate; import org.junit.jupiter.api.Disabled; @@ -109,9 +109,9 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTestWithClean assertThat(debitorRepo.count()).isEqualTo(count + 1); } + @Transactional @ParameterizedTest @ValueSource(strings = {"", "a", "ab", "a12", "123", "12a"}) - @Transactional public void canNotCreateNewDebitorWithInvalidDefaultPrefix(final String givenPrefix) { // given context("superuser-alex@hostsharing.net"); @@ -181,7 +181,7 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTestWithClean .containsExactlyInAnyOrder(Array.fromFormatted( initialGrantNames, "{ grant perm:relation#FirstGmbH-with-DEBITOR-FourtheG:INSERT>sepamandate to role:relation#FirstGmbH-with-DEBITOR-FourtheG:ADMIN by system and assume }", - "{ grant perm:relation#FirstGmbH-with-DEBITOR-FourtheG:INSERT>hs_booking_item to role:relation#FirstGmbH-with-DEBITOR-FourtheG:ADMIN by system and assume }", + "{ grant perm:relation#FirstGmbH-with-DEBITOR-FourtheG:INSERT>hs_booking_project to role:relation#FirstGmbH-with-DEBITOR-FourtheG:ADMIN by system and assume }", // owner "{ grant perm:debitor#D-1000122:DELETE to role:relation#FirstGmbH-with-DEBITOR-FourtheG:OWNER by system and assume }", diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/TestHsOfficeDebitor.java b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/TestHsOfficeDebitor.java index 4305b87a..b8ddf8b5 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/TestHsOfficeDebitor.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/debitor/TestHsOfficeDebitor.java @@ -20,5 +20,6 @@ public class TestHsOfficeDebitor { .contact(TEST_CONTACT) .build()) .partner(TEST_PARTNER) + .defaultPrefix("abc") .build(); } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java index 1cba78da..701e6651 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java @@ -7,7 +7,7 @@ import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/migration/ImportOfficeData.java b/src/test/java/net/hostsharing/hsadminng/hs/office/migration/ImportOfficeData.java index e2e5823f..c2ea4efe 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/migration/ImportOfficeData.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/migration/ImportOfficeData.java @@ -634,6 +634,7 @@ public class ImportOfficeData extends ContextBasedTest { context(rbacSuperuser); em.createNativeQuery("delete from hs_hosting_asset where true").executeUpdate(); em.createNativeQuery("delete from hs_booking_item where true").executeUpdate(); + em.createNativeQuery("delete from hs_booking_project where true").executeUpdate(); em.createNativeQuery("delete from hs_office_coopassetstransaction where true").executeUpdate(); em.createNativeQuery("delete from hs_office_coopassetstransaction_legacy_id where true").executeUpdate(); em.createNativeQuery("delete from hs_office_coopsharestransaction where true").executeUpdate(); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java index 39faf7eb..5daf0f8f 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java @@ -30,7 +30,7 @@ import java.util.Objects; import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacObjectEntity.objectDisplaysOf; import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; -import static net.hostsharing.hsadminng.rbac.test.Array.from; +import static net.hostsharing.hsadminng.mapper.Array.from; import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java index 7ce2fdf1..efd7064f 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/person/HsOfficePersonRepositoryIntegrationTest.java @@ -4,7 +4,7 @@ import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.Nested; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java index fe9e2ef1..0792d656 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/relation/HsOfficeRelationRepositoryIntegrationTest.java @@ -6,7 +6,7 @@ import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonRepository; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepositoryIntegrationTest.java index 5544a3e3..ad7ee76e 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/sepamandate/HsOfficeSepaMandateRepositoryIntegrationTest.java @@ -7,7 +7,7 @@ import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -26,7 +26,7 @@ import java.util.List; import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.distinctGrantDisplaysOf; import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.distinctRoleNamesOf; -import static net.hostsharing.hsadminng.rbac.test.Array.fromFormatted; +import static net.hostsharing.hsadminng.mapper.Array.fromFormatted; import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/validation/PasswordPropertyUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/validation/PasswordPropertyUnitTest.java new file mode 100644 index 00000000..2350b288 --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/hs/validation/PasswordPropertyUnitTest.java @@ -0,0 +1,120 @@ +package net.hostsharing.hsadminng.hs.validation; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static net.hostsharing.hsadminng.hash.LinuxEtcShadowHashGenerator.Algorithm.SHA512; +import static net.hostsharing.hsadminng.hash.LinuxEtcShadowHashGenerator.hash; +import static net.hostsharing.hsadminng.hs.validation.PasswordProperty.passwordProperty; +import static net.hostsharing.hsadminng.mapper.PatchableMapWrapper.entry; +import static org.assertj.core.api.Assertions.assertThat; + +class PasswordPropertyUnitTest { + + private final ValidatableProperty passwordProp = + passwordProperty("password").minLength(8).maxLength(40).hashedUsing(SHA512).writeOnly(); + private final List violations = new ArrayList<>(); + + @ParameterizedTest + @ValueSource(strings = { + "lowerUpperAndDigit1", + "lowerUpperAndSpecial!", + "digit1LowerAndSpecial!", + "digit1special!lower", + "DIGIT1SPECIAL!UPPER" }) + void shouldValidateValidPassword(final String password) { + // when + passwordProp.validate(violations, password, null); + + // then + assertThat(violations).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { + "noDigitNoSpecial", + "!!!!!!12345", + "nolower-nodigit", + "nolower1nospecial", + "NOLOWER-NODIGIT", + "NOLOWER1NOSPECIAL" + }) + void shouldRecognizeMissingCharacterGroup(final String givenPassword) { + // when + passwordProp.validate(violations, givenPassword, null); + + // then + assertThat(violations) + .contains("password' must contain at least one character of at least 3 of the following groups: upper case letters, lower case letters, digits, special characters") + .doesNotContain(givenPassword); + } + + @Test + void shouldRecognizeTooShortPassword() { + // given + final String givenPassword = "0123456"; + + // when + passwordProp.validate(violations, givenPassword, null); + + // then + assertThat(violations) + .contains("password' length is expected to be at min 8 but length of provided value is 7") + .doesNotContain(givenPassword); + } + + @Test + void shouldRecognizeTooLongPassowrd() { + // given + final String givenPassword = "password' length is expected to be at max 40 but is 41"; + + // when + passwordProp.validate(violations, givenPassword, null); + + // then + assertThat(violations).contains("password' length is expected to be at max 40 but length of provided value is 54") + .doesNotContain(givenPassword); + } + + @Test + void shouldRecognizeColonInPassword() { + // given + final String givenPassword = "lowerUpper:1234"; + + // when + passwordProp.validate(violations, givenPassword, null); + + // then + assertThat(violations) + .contains("password' must not contain colon (':')") + .doesNotContain(givenPassword); + } + + @Test + void shouldComputeHash() { + + // when + final var result = passwordProp.compute(new PropertiesProvider() { + + @Override + public Map directProps() { + return Map.ofEntries( + entry(passwordProp.propertyName, "some password") + ); + } + + @Override + public Object getContextValue(final String propName) { + return null; + } + }); + + // then + hash("some password").using(SHA512).withRandomSalt().generate(); // throws exception if wrong + } +} diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextIntegrationTests.java b/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextIntegrationTests.java index 11cda37f..22e1df04 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextIntegrationTests.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/context/ContextIntegrationTests.java @@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.rbac.context; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.mapper.Mapper; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleRepositoryIntegrationTest.java index 536d748c..e7a28261 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacrole/RbacRoleRepositoryIntegrationTest.java @@ -1,7 +1,7 @@ package net.hostsharing.hsadminng.rbac.rbacrole; import net.hostsharing.hsadminng.context.Context; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java index f1e6fef5..be6377a0 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/rbacuser/RbacUserRepositoryIntegrationTest.java @@ -2,7 +2,7 @@ package net.hostsharing.hsadminng.rbac.rbacuser; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.rbac.context.ContextBasedTest; -import net.hostsharing.hsadminng.rbac.test.Array; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java index 154dbb11..5e9d8347 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/ContextBasedTestWithCleanup.java @@ -14,9 +14,12 @@ import org.junit.jupiter.api.TestInfo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.transaction.PlatformTransactionManager; import jakarta.persistence.*; import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; import static java.lang.System.out; import static java.util.Comparator.comparing; @@ -28,9 +31,13 @@ import static org.assertj.core.api.Assertions.assertThat; public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { private static final boolean DETAILED_BUT_SLOW_CHECK = true; + @PersistenceContext protected EntityManager em; + @Autowired + private PlatformTransactionManager tm; + @Autowired RbacGrantRepository rbacGrantRepo; @@ -63,7 +70,6 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { return merged; } - // remove HsOfficeCoopAssetsTransactionRawEntity, which is not needed anymore after this change public UUID toCleanup(final Class entityClass, final UUID uuidToCleanup) { out.println("toCleanup(" + entityClass.getSimpleName() + ", " + uuidToCleanup + ")"); entitiesToCleanup.put(uuidToCleanup, entityClass); @@ -167,16 +173,21 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { @AfterEach void cleanupAndCheckCleanup(final TestInfo testInfo) { - out.println(ContextBasedTestWithCleanup.class.getSimpleName() + ".cleanupAndCheckCleanup"); - cleanupTemporaryTestData(); - deleteLeakedRbacObjects(); - long rbacObjectCount = assertNoNewRbacObjectsRolesAndGrantsLeaked(); + // If the whole test method has its own transaction, cleanup makes no sense. + // If that transaction even failed, cleaunup would cause an exception. + if (!tm.getTransaction(null).isRollbackOnly()) { + out.println(ContextBasedTestWithCleanup.class.getSimpleName() + ".cleanupAndCheckCleanup"); + cleanupTemporaryTestData(); + repeatUntilTrue(3, this::deleteLeakedRbacObjects); - out.println("TOTAL OBJECT COUNT (after): " + rbacObjectCount); + long rbacObjectCount = assertNoNewRbacObjectsRolesAndGrantsLeaked(); + out.println("TOTAL OBJECT COUNT (after): " + rbacObjectCount); + } } private void cleanupTemporaryTestData() { - jpaAttempt.transacted(() -> { + // For better performance in a single transaction ... + final var exception = jpaAttempt.transacted(() -> { context.define("superuser-alex@hostsharing.net", null); entitiesToCleanup.reversed().forEach((uuid, entityClass) -> { final var rvTableName = entityClass.getAnnotation(Table.class).name(); @@ -188,7 +199,12 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { .setParameter("uuid", uuid).executeUpdate(); out.println("DELETING temporary " + entityClass.getSimpleName() + "#" + uuid + " deleted " + deletedRows + " rows"); }); - }).assertSuccessful(); + }).caughtException(); + + // ... and in case of foreign key violations, we rely on the RbacObject cleanup. + if (exception != null) { + System.err.println(exception); + } } private long assertNoNewRbacObjectsRolesAndGrantsLeaked() { @@ -213,25 +229,28 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { }).assertSuccessful().returnedValue(); } - private void deleteLeakedRbacObjects() { - jpaAttempt.transacted(() -> rbacObjectRepo.findAll()).returnedValue().stream() - .filter(o -> o.serialId > latestIntialTestDataSerialId) - .sorted(comparing(o -> o.serialId)) - .forEach(o -> { - final var exception = jpaAttempt.transacted(() -> { - context.define("superuser-alex@hostsharing.net", null); + private boolean deleteLeakedRbacObjects() { + final var deletionSuccessful = new AtomicBoolean(true); + rbacObjectRepo.findAll().stream() + .filter(o -> o.serialId > latestIntialTestDataSerialId) + .sorted(comparing(o -> o.serialId)) + .forEach(o -> { + final var exception = jpaAttempt.transacted(() -> { + context.define("superuser-alex@hostsharing.net", null); - em.createNativeQuery("DELETE FROM " + o.objectTable + " WHERE uuid=:uuid") - .setParameter("uuid", o.uuid) - .executeUpdate(); + em.createNativeQuery("DELETE FROM " + o.objectTable + " WHERE uuid=:uuid") + .setParameter("uuid", o.uuid) + .executeUpdate(); - out.println("DELETING leaked " + o.objectTable + "#" + o.uuid + " SUCCEEDED"); - }).caughtException(); + out.println("DELETING leaked " + o.objectTable + "#" + o.uuid + " SUCCEEDED"); + }).caughtException(); - if (exception != null) { - out.println("DELETING leaked " + o.objectTable + "#" + o.uuid + " FAILED " + exception); - } - }); + if (exception != null) { + out.println("DELETING leaked " + o.objectTable + "#" + o.uuid + " FAILED " + exception); + deletionSuccessful.set(false); + } + }); + return deletionSuccessful.get(); } private void assertEqual(final Set before, final Set after) { @@ -292,6 +311,15 @@ public abstract class ContextBasedTestWithCleanup extends ContextBasedTest { "doc/temp/" + name + ".md" ); } + + public static boolean repeatUntilTrue(int maxAttempts, Supplier method) { + for (int attempts = 0; attempts < maxAttempts; attempts++) { + if (method.get()) { + return true; + } + } + return false; + } } interface RbacObjectRepository extends Repository { diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/test/JsonMatcher.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/JsonMatcher.java index 54208e4c..22ddead9 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/test/JsonMatcher.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/JsonMatcher.java @@ -9,13 +9,15 @@ import org.json.JSONException; import org.skyscreamer.jsonassert.JSONAssert; import org.skyscreamer.jsonassert.JSONCompareMode; +import static com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT; + public class JsonMatcher extends BaseMatcher { - private final String expected; + private final String expectedJson; private JSONCompareMode compareMode; - public JsonMatcher(final String expected, final JSONCompareMode compareMode) { - this.expected = expected; + public JsonMatcher(final String expectedJson, final JSONCompareMode compareMode) { + this.expectedJson = expectedJson; this.compareMode = compareMode; } @@ -47,8 +49,8 @@ public class JsonMatcher extends BaseMatcher { return false; } try { - final var actualJson = new ObjectMapper().writeValueAsString(actual); - JSONAssert.assertEquals(expected, actualJson, compareMode); + final var actualJson = new ObjectMapper().enable(INDENT_OUTPUT).writeValueAsString(actual); + JSONAssert.assertEquals(expectedJson, actualJson, compareMode); return true; } catch (final JSONException | JsonProcessingException e) { throw new AssertionError(e); @@ -59,5 +61,4 @@ public class JsonMatcher extends BaseMatcher { public void describeTo(final Description description) { description.appendText("leniently matches JSON"); } - } diff --git a/src/test/java/net/hostsharing/hsadminng/system/SystemProcessUnitTest.java b/src/test/java/net/hostsharing/hsadminng/system/SystemProcessUnitTest.java new file mode 100644 index 00000000..5025555c --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/system/SystemProcessUnitTest.java @@ -0,0 +1,81 @@ +package net.hostsharing.hsadminng.system; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.junit.jupiter.api.condition.OS.LINUX; + +class SystemProcessUnitTest { + + @Test + @EnabledOnOs(LINUX) + void shouldExecuteAndFetchOutput() throws IOException, InterruptedException { + // given + final var process = new SystemProcess("bash", "-c", "echo 'Hello, World!'; echo 'Error!' >&2"); + + // when + final var returnCode = process.execute(); + + // then + assertThat(returnCode).isEqualTo(0); + assertThat(process.getStdOut()).isEqualTo("Hello, World!\n"); + assertThat(process.getStdErr()).isEqualTo("Error!\n"); + } + + @Test + @EnabledOnOs(LINUX) + void shouldReturnErrorCode() throws IOException, InterruptedException { + // given + final var process = new SystemProcess("false"); + + // when + final int returnCode = process.execute(); + + // then + assertThat(returnCode).isEqualTo(1); + } + + @Test + @EnabledOnOs(LINUX) + void shouldExecuteAndFeedInput() throws IOException, InterruptedException { + // given + final var process = new SystemProcess("tr", "[:lower:]", "[:upper:]"); + + // when + final int returnCode = process.execute("Hallo"); + + // then + assertThat(returnCode).isEqualTo(0); + assertThat(process.getStdOut()).isEqualTo("HALLO\n"); + } + + @Test + void shouldThrowExceptionIfProgramNotFound() { + // given + final var process = new SystemProcess("non-existing program"); + + // when + final var exception = catchThrowable(process::execute); + + // then + assertThat(exception).isInstanceOf(IOException.class) + .hasMessage("Cannot run program \"non-existing program\": error=2, No such file or directory"); + } + + @Test + void shouldBeAbleToRunMultipleTimes() throws IOException, InterruptedException { + // given + final var process = new SystemProcess("true"); + + // when + process.execute(); + final int returnCode = process.execute(); + + // then + assertThat(returnCode).isEqualTo(0); + } +}