introduce-booking-module (#41)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: #41
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
This commit is contained in:
Michael Hoennig 2024-04-16 11:21:34 +02:00
parent f0eb76ee61
commit 661b06859f
33 changed files with 2313 additions and 13 deletions

View File

@ -140,7 +140,7 @@ openapiProcessor {
showWarnings true showWarnings true
openApiNullable true openApiNullable true
} }
springHs { springHsOffice {
processorName 'spring' processorName 'spring'
processor 'io.openapiprocessor:openapi-processor-spring:2024.2' processor 'io.openapiprocessor:openapi-processor-spring:2024.2'
apiPath "$projectDir/src/main/resources/api-definition/hs-office/hs-office.yaml" apiPath "$projectDir/src/main/resources/api-definition/hs-office/hs-office.yaml"
@ -149,11 +149,25 @@ openapiProcessor {
showWarnings true showWarnings true
openApiNullable true openApiNullable true
} }
springHsBooking {
processorName 'spring'
processor 'io.openapiprocessor:openapi-processor-spring:2024.2'
apiPath "$projectDir/src/main/resources/api-definition/hs-booking/hs-booking.yaml"
mapping "$projectDir/src/main/resources/api-definition/hs-booking/api-mappings.yaml"
targetDir "$buildDir/generated/sources/openapi-javax"
showWarnings true
openApiNullable true
}
} }
sourceSets.main.java.srcDir 'build/generated/sources/openapi' sourceSets.main.java.srcDir 'build/generated/sources/openapi'
abstract class ProcessSpring extends DefaultTask {} abstract class ProcessSpring extends DefaultTask {}
tasks.register('processSpring', ProcessSpring) tasks.register('processSpring', ProcessSpring)
['processSpringRoot', 'processSpringRbac', 'processSpringTest', 'processSpringHs'].each { ['processSpringRoot',
'processSpringRbac',
'processSpringTest',
'processSpringHsOffice',
'processSpringHsBooking'
].each {
project.tasks.processSpring.dependsOn it project.tasks.processSpring.dependsOn it
} }
project.tasks.processResources.dependsOn processSpring project.tasks.processResources.dependsOn processSpring

View File

@ -0,0 +1,131 @@
package net.hostsharing.hsadminng.hs.booking.item;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.api.HsBookingItemsApi;
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.mapper.KeyValueMap;
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 java.util.List;
import java.util.UUID;
import java.util.function.BiConsumer;
import static net.hostsharing.hsadminng.mapper.PostgresDateRange.toPostgresDateRange;
@RestController
public class HsBookingItemController implements HsBookingItemsApi {
@Autowired
private Context context;
@Autowired
private Mapper mapper;
@Autowired
private HsBookingItemRepository bookingItemRepo;
@Override
@Transactional(readOnly = true)
public ResponseEntity<List<HsBookingItemResource>> listBookingItemsByDebitorUuid(
final String currentUser,
final String assumedRoles,
final UUID debitorUuid) {
context.define(currentUser, assumedRoles);
final var entities = bookingItemRepo.findAllByDebitorUuid(debitorUuid);
final var resources = mapper.mapList(entities, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(resources);
}
@Override
@Transactional
public ResponseEntity<HsBookingItemResource> addBookingItem(
final String currentUser,
final String assumedRoles,
final HsBookingItemInsertResource body) {
context.define(currentUser, assumedRoles);
final var entityToSave = mapper.map(body, HsBookingItemEntity.class, RESOURCE_TO_ENTITY_POSTMAPPER);
final var saved = bookingItemRepo.save(entityToSave);
final var uri =
MvcUriComponentsBuilder.fromController(getClass())
.path("/api/hs/booking/items/{id}")
.buildAndExpand(saved.getUuid())
.toUri();
final var mapped = mapper.map(saved, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.created(uri).body(mapped);
}
@Override
@Transactional(readOnly = true)
public ResponseEntity<HsBookingItemResource> getBookingItemByUuid(
final String currentUser,
final String assumedRoles,
final UUID bookingItemUuid) {
context.define(currentUser, assumedRoles);
final var result = bookingItemRepo.findByUuid(bookingItemUuid);
return result
.map(bookingItemEntity -> ResponseEntity.ok(
mapper.map(bookingItemEntity, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER)))
.orElseGet(() -> ResponseEntity.notFound().build());
}
@Override
@Transactional
public ResponseEntity<Void> deleteBookingIemByUuid(
final String currentUser,
final String assumedRoles,
final UUID bookingItemUuid) {
context.define(currentUser, assumedRoles);
final var result = bookingItemRepo.deleteByUuid(bookingItemUuid);
return result == 0
? ResponseEntity.notFound().build()
: ResponseEntity.noContent().build();
}
@Override
@Transactional
public ResponseEntity<HsBookingItemResource> patchBookingItem(
final String currentUser,
final String assumedRoles,
final UUID bookingItemUuid,
final HsBookingItemPatchResource body) {
context.define(currentUser, assumedRoles);
final var current = bookingItemRepo.findByUuid(bookingItemUuid).orElseThrow();
new HsBookingItemEntityPatcher(current).apply(body);
final var saved = bookingItemRepo.save(current);
final var mapped = mapper.map(saved, HsBookingItemResource.class, ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(mapped);
}
final BiConsumer<HsBookingItemEntity, HsBookingItemResource> ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
resource.setValidFrom(entity.getValidity().lower());
if (entity.getValidity().hasUpperBound()) {
resource.setValidTo(entity.getValidity().upper().minusDays(1));
}
};
@SuppressWarnings("unchecked")
final BiConsumer<HsBookingItemInsertResource, HsBookingItemEntity> RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
entity.setValidity(toPostgresDateRange(resource.getValidFrom(), resource.getValidTo()));
entity.putResources(KeyValueMap.from(resource.getResources()));
};
}

View File

@ -0,0 +1,182 @@
package net.hostsharing.hsadminng.hs.booking.item;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import io.hypersistence.utils.hibernate.type.range.PostgreSQLRangeType;
import io.hypersistence.utils.hibernate.type.range.Range;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
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.mapper.PatchableMapWrapper;
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 org.hibernate.annotations.Type;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import jakarta.persistence.Version;
import java.io.IOException;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import static java.util.Optional.ofNullable;
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.Nullable.NOT_NULL;
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.Role.ADMIN;
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
@Table(name = "hs_booking_item_rv")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class HsBookingItemEntity implements Stringifyable, RbacObject {
private static Stringify<HsBookingItemEntity> stringify = stringify(HsBookingItemEntity.class)
.withProp(e -> e.getDebitor().toShortString())
.withProp(e -> e.getValidity().asString())
.withProp(HsBookingItemEntity::getCaption)
.withProp(HsBookingItemEntity::getResources)
.quotedValues(false);
@Id
@GeneratedValue
private UUID uuid;
@Version
private int version;
@ManyToOne(optional = false)
@JoinColumn(name = "debitoruuid")
private HsOfficeDebitorEntity debitor;
@Builder.Default
@Type(PostgreSQLRangeType.class)
@Column(name = "validity", columnDefinition = "daterange")
private Range<LocalDate> validity = Range.emptyRange(LocalDate.class);
@Column(name = "caption")
private String caption;
@Builder.Default
@Setter(AccessLevel.NONE)
@Type(JsonType.class)
@Column(columnDefinition = "resources")
private Map<String, Object> resources = new HashMap<>();
@Transient
private PatchableMapWrapper resourcesWrapper;
public void setValidFrom(final LocalDate validFrom) {
setValidity(toPostgresDateRange(validFrom, getValidTo()));
}
public void setValidTo(final LocalDate validTo) {
setValidity(toPostgresDateRange(getValidFrom(), validTo));
}
public LocalDate getValidFrom() {
return lowerInclusiveFromPostgresDateRange(getValidity());
}
public LocalDate getValidTo() {
return upperInclusiveFromPostgresDateRange(getValidity());
}
public PatchableMapWrapper getResources() {
if ( resourcesWrapper == null ) {
resourcesWrapper = new PatchableMapWrapper(resources);
}
return resourcesWrapper;
}
public void putResources(Map<String, Object> entries) {
if ( resourcesWrapper == null ) {
resourcesWrapper = new PatchableMapWrapper(resources);
}
resourcesWrapper.assign(entries);
}
@Override
public String toString() {
return stringify.apply(this);
}
@Override
public String toShortString() {
return ofNullable(debitor).map(HsOfficeDebitorEntity::toShortString).orElse("D-???????") +
":" + caption;
}
public static RbacView rbac() {
return rbacViewFor("bookingItem", HsBookingItemEntity.class)
.withIdentityView(SQL.query("""
SELECT i.uuid as uuid, d.idName || ':' || i.caption as idName
FROM hs_booking_item i
JOIN hs_office_debitor_iv d ON d.uuid = i.debitorUuid
"""))
.withRestrictedViewOrderBy(SQL.expression("validity"))
.withUpdatableColumns("version", "validity", "resources")
.importEntityAlias("debitor", HsOfficeDebitorEntity.class,
dependsOnColumn("debitorUuid"),
directlyFetchedByDependsOnColumn(),
NOT_NULL)
.importEntityAlias("debitorRel", HsOfficeRelationEntity.class,
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);
with.permission(UPDATE);
})
.createSubRole(ADMIN)
.createSubRole(TENANT, (with) -> {
with.outgoingSubRole("debitorRel", TENANT);
with.permission(SELECT);
});
}
public static void main(String[] args) throws IOException {
rbac().generateWithBaseFileName("6-hs-booking/601-booking-item/6013-hs-booking-item-rbac");
}
}

View File

@ -0,0 +1,28 @@
package net.hostsharing.hsadminng.hs.booking.item;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemPatchResource;
import net.hostsharing.hsadminng.mapper.EntityPatcher;
import net.hostsharing.hsadminng.mapper.KeyValueMap;
import net.hostsharing.hsadminng.mapper.OptionalFromJson;
import java.util.Optional;
public class HsBookingItemEntityPatcher implements EntityPatcher<HsBookingItemPatchResource> {
private final HsBookingItemEntity entity;
public HsBookingItemEntityPatcher(final HsBookingItemEntity entity) {
this.entity = entity;
}
@Override
public void apply(final HsBookingItemPatchResource resource) {
OptionalFromJson.of(resource.getCaption())
.ifPresent(entity::setCaption);
Optional.ofNullable(resource.getResources())
.ifPresent(r -> entity.getResources().patch(KeyValueMap.from(resource.getResources())));
OptionalFromJson.of(resource.getValidTo())
.ifPresent(entity::setValidTo);
}
}

View File

@ -0,0 +1,21 @@
package net.hostsharing.hsadminng.hs.booking.item;
import org.springframework.data.repository.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface HsBookingItemRepository extends Repository<HsBookingItemEntity, UUID> {
List<HsBookingItemEntity> findAll();
Optional<HsBookingItemEntity> findByUuid(final UUID bookingItemUuid);
List<HsBookingItemEntity> findAllByDebitorUuid(final UUID bookingItemUuid);
HsBookingItemEntity save(HsBookingItemEntity current);
int deleteByUuid(final UUID uuid);
long count();
}

View File

@ -74,11 +74,11 @@ public class HsOfficeBankAccountController implements HsOfficeBankAccountsApi {
public ResponseEntity<HsOfficeBankAccountResource> getBankAccountByUuid( public ResponseEntity<HsOfficeBankAccountResource> getBankAccountByUuid(
final String currentUser, final String currentUser,
final String assumedRoles, final String assumedRoles,
final UUID BankAccountUuid) { final UUID bankAccountUuid) {
context.define(currentUser, assumedRoles); context.define(currentUser, assumedRoles);
final var result = bankAccountRepo.findByUuid(BankAccountUuid); final var result = bankAccountRepo.findByUuid(bankAccountUuid);
if (result.isEmpty()) { if (result.isEmpty()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }

View File

@ -18,7 +18,6 @@ import jakarta.persistence.*;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static java.util.Optional.ofNullable; import static java.util.Optional.ofNullable;

View File

@ -2,9 +2,7 @@ package net.hostsharing.hsadminng.hs.office.coopshares;
import jakarta.persistence.EntityNotFoundException; import jakarta.persistence.EntityNotFoundException;
import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopSharesApi; import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeCoopSharesApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopAssetsTransactionInsertResource;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeCoopSharesTransactionInsertResource; 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.hs.office.generated.api.v1.model.HsOfficeCoopSharesTransactionResource;
import net.hostsharing.hsadminng.mapper.Mapper; import net.hostsharing.hsadminng.mapper.Mapper;

View File

@ -6,7 +6,6 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import net.hostsharing.hsadminng.errors.DisplayName; import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionEntity;
import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity; import net.hostsharing.hsadminng.hs.office.membership.HsOfficeMembershipEntity;
import net.hostsharing.hsadminng.rbac.rbacdef.RbacView; import net.hostsharing.hsadminng.rbac.rbacdef.RbacView;
import net.hostsharing.hsadminng.rbac.rbacobject.RbacObject; import net.hostsharing.hsadminng.rbac.rbacobject.RbacObject;

View File

@ -0,0 +1,14 @@
package net.hostsharing.hsadminng.mapper;
import java.util.Map;
public class KeyValueMap {
@SuppressWarnings("unchecked")
public static Map<String, Object> from(final Object obj) {
if (obj instanceof Map<?, ?>) {
return (Map<String, Object>) obj;
}
throw new ClassCastException("Map expected, but got: " + obj);
}
}

View File

@ -0,0 +1,30 @@
package net.hostsharing.hsadminng.mapper;
import org.apache.commons.lang3.tuple.ImmutablePair;
import jakarta.validation.constraints.NotNull;
import java.util.Map;
import java.util.TreeMap;
import static java.util.Arrays.stream;
/**
* This is a map which can take key-value-pairs where the value can be null
* thus JSON nullable object structures from HTTP PATCH can be represented.
*/
public class PatchMap extends TreeMap<String, Object> {
public PatchMap(final ImmutablePair<String, Object>[] entries) {
stream(entries).forEach(r -> put(r.getKey(), r.getValue()));
}
@SafeVarargs
public static Map<String, Object> patchMap(final ImmutablePair<String, Object>... entries) {
return new PatchMap(entries);
}
@NotNull
public static ImmutablePair<String, Object> entry(final String key, final Object value) {
return new ImmutablePair<>(key, value);
}
}

View File

@ -0,0 +1,114 @@
package net.hostsharing.hsadminng.mapper;
import org.apache.commons.lang3.tuple.ImmutablePair;
import jakarta.validation.constraints.NotNull;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import static java.util.stream.Collectors.joining;
/** This class wraps another (usually persistent) map and
* supports applying `PatchMap` as well as a toString method with stable entry order.
*/
public class PatchableMapWrapper implements Map<String, Object> {
private final Map<String, Object> delegate;
public PatchableMapWrapper(final Map<String, Object> map) {
delegate = map;
}
@NotNull
public static ImmutablePair<String, Object> entry(final String key, final Object value) {
return new ImmutablePair<>(key, value);
}
public void assign(final Map<String, Object> entries) {
delegate.clear();
delegate.putAll(entries);
}
public void patch(final Map<String, Object> patch) {
patch.forEach((key, value) -> {
if (value == null) {
remove(key);
} else {
put(key, value);
}
});
}
public String toString() {
return "{ "
+ (
keySet().stream().sorted()
.map(k -> k + ": " + get(k)))
.collect(joining(", ")
)
+ " }";
}
// --- below just delegating methods --------------------------------
@Override
public int size() {
return delegate.size();
}
@Override
public boolean isEmpty() {
return delegate.isEmpty();
}
@Override
public boolean containsKey(final Object key) {
return delegate.containsKey(key);
}
@Override
public boolean containsValue(final Object value) {
return delegate.containsValue(value);
}
@Override
public Object get(final Object key) {
return delegate.get(key);
}
@Override
public Object put(final String key, final Object value) {
return delegate.put(key, value);
}
@Override
public Object remove(final Object key) {
return delegate.remove(key);
}
@Override
public void putAll(final Map<? extends String, ?> m) {
delegate.putAll(m);
}
@Override
public void clear() {
delegate.clear();
}
@Override
public Set<String> keySet() {
return delegate.keySet();
}
@Override
public Collection<Object> values() {
return delegate.values();
}
@Override
public Set<Entry<String, Object>> entrySet() {
return delegate.entrySet();
}
}

View File

@ -0,0 +1,17 @@
openapi-processor-mapping: v2
options:
package-name: net.hostsharing.hsadminng.hs.booking.generated.api.v1
model-name-suffix: Resource
bean-validation: true
map:
result: org.springframework.http.ResponseEntity
types:
- type: array => java.util.List
- type: string:uuid => java.util.UUID
paths:
/api/hs/booking/items/{bookingItemUuid}:
null: org.openapitools.jackson.nullable.JsonNullable

View File

@ -0,0 +1,20 @@
components:
parameters:
currentUser:
name: current-user
in: header
required: true
schema:
type: string
description: Identifying name of the currently logged in user.
assumedRoles:
name: assumed-roles
in: header
required: false
schema:
type: string
description: Semicolon-separated list of roles to assume. The current user needs to have the right to assume these roles.

View File

@ -0,0 +1,40 @@
components:
responses:
NotFound:
description: The specified was not found.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Unauthorized:
description: The current user is unknown or not authorized.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Forbidden:
description: The current user or none of the assumed or roles is granted access to the resource.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Conflict:
description: The request could not be completed due to a conflict with the current state of the target resource.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
schemas:
Error:
type: object
properties:
code:
type: string
message:
type: string
required:
- code
- message

View File

@ -0,0 +1,113 @@
components:
schemas:
HsBookingItem:
type: object
properties:
uuid:
type: string
format: uuid
caption:
type: string
validFrom:
type: string
format: date
validTo:
type: string
format: date
resources:
$ref: '#/components/schemas/BookingResources'
required:
- uuid
- validFrom
- validTo
- resources
HsBookingItemPatch:
type: object
properties:
caption:
type: string
nullable: true
validTo:
type: string
format: date
nullable: true
resources:
$ref: '#/components/schemas/BookingResources'
HsBookingItemInsert:
type: object
properties:
debitorUuid:
type: string
format: uuid
nullable: false
caption:
type: string
minLength: 3
maxLength:
nullable: false
validFrom:
type: string
format: date
nullable: false
validTo:
type: string
format: date
nullable: true
resources:
$ref: '#/components/schemas/BookingResources'
required:
- caption
- debitorUuid
- validFrom
- resources
additionalProperties: false
BookingResources:
anyOf:
- $ref: '#/components/schemas/ManagedServerBookingResources'
- $ref: '#/components/schemas/ManagedWebspaceBookingResources'
ManagedServerBookingResources:
type: object
properties:
caption:
type: string
minLength: 3
maxLength:
nullable: false
CPU:
type: integer
minimum: 1
maximum: 16
SSD:
type: integer
minimum: 16
maximum: 4096
HDD:
type: integer
minimum: 16
maximum: 4096
additionalProperties: false
ManagedWebspaceBookingResources:
type: object
properties:
disk:
type: integer
minimum: 1
maximum: 16
SSD:
type: integer
minimum: 16
maximum: 4096
HDD:
type: integer
minimum: 16
maximum: 4096
additionalProperties: false

View File

@ -0,0 +1,83 @@
get:
tags:
- hs-booking-items
description: 'Fetch a single booking item its uuid, if visible for the current subject.'
operationId: getBookingItemByUuid
parameters:
- $ref: 'auth.yaml#/components/parameters/currentUser'
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: bookingItemUuid
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the booking item to fetch.
responses:
"200":
description: OK
content:
'application/json':
schema:
$ref: 'hs-booking-item-schemas.yaml#/components/schemas/HsBookingItem'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
patch:
tags:
- hs-booking-items
description: 'Updates a single booking item identified by its uuid, if permitted for the current subject.'
operationId: patchBookingItem
parameters:
- $ref: 'auth.yaml#/components/parameters/currentUser'
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
- name: bookingItemUuid
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
'application/json':
schema:
$ref: 'hs-booking-item-schemas.yaml#/components/schemas/HsBookingItemPatch'
responses:
"200":
description: OK
content:
'application/json':
schema:
$ref: 'hs-booking-item-schemas.yaml#/components/schemas/HsBookingItem'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
delete:
tags:
- hs-booking-items
description: 'Delete a single booking item 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: bookingItemUuid
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the booking item 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'

View File

@ -0,0 +1,58 @@
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.
tags:
- hs-booking-items
operationId: listBookingItemsByDebitorUuid
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 items are to be listed.
responses:
"200":
description: OK
content:
'application/json':
schema:
type: array
items:
$ref: 'hs-booking-item-schemas.yaml#/components/schemas/HsBookingItem'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
post:
summary: Adds a new booking item.
tags:
- hs-booking-items
operationId: addBookingItem
parameters:
- $ref: 'auth.yaml#/components/parameters/currentUser'
- $ref: 'auth.yaml#/components/parameters/assumedRoles'
requestBody:
description: A JSON object describing the new booking item.
required: true
content:
application/json:
schema:
$ref: 'hs-booking-item-schemas.yaml#/components/schemas/HsBookingItemInsert'
responses:
"201":
description: Created
content:
'application/json':
schema:
$ref: 'hs-booking-item-schemas.yaml#/components/schemas/HsBookingItem'
"401":
$ref: 'error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: 'error-responses.yaml#/components/responses/Forbidden'
"409":
$ref: 'error-responses.yaml#/components/responses/Conflict'

View File

@ -0,0 +1,17 @@
openapi: 3.0.3
info:
title: Hostsharing hsadmin-ng API
version: v0
servers:
- url: http://localhost:8080
description: Local development default URL.
paths:
# Items
/api/hs/booking/items:
$ref: "hs-booking-items.yaml"
/api/hs/booking/items/{bookingItemUuid}:
$ref: "hs-booking-items-with-uuid.yaml"

View File

@ -7,7 +7,7 @@
create table if not exists hs_office_sepamandate create table if not exists hs_office_sepamandate
( (
uuid uuid unique references RbacObject (uuid) initially deferred, uuid uuid unique references RbacObject (uuid) initially deferred,
version int not null default 0, version int not null default 0,
debitorUuid uuid not null references hs_office_debitor(uuid), debitorUuid uuid not null references hs_office_debitor(uuid),
bankAccountUuid uuid not null references hs_office_bankaccount(uuid), bankAccountUuid uuid not null references hs_office_bankaccount(uuid),
reference varchar(96) not null, reference varchar(96) not null,

View File

@ -0,0 +1,24 @@
--liquibase formatted sql
-- ============================================================================
--changeset booking-item-MAIN-TABLE:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
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),
validity daterange not null,
caption varchar(80) not null,
resources jsonb not null
);
--//
-- ============================================================================
--changeset hs-booking-item-MAIN-TABLE-JOURNAL:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call create_journal('hs_booking_item');
--//

View File

@ -0,0 +1,285 @@
### rbac bookingItem
This code generated was by RbacViewMermaidFlowchartGenerator, do not amend manually.
```mermaid
%%{init:{'flowchart':{'htmlLabels':false}}}%%
flowchart TB
subgraph debitor.debitorRel.anchorPerson["`**debitor.debitorRel.anchorPerson**`"]
direction TB
style debitor.debitorRel.anchorPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph debitor.debitorRel.anchorPerson:roles[ ]
style debitor.debitorRel.anchorPerson:roles fill:#99bcdb,stroke:white
role:debitor.debitorRel.anchorPerson:OWNER[[debitor.debitorRel.anchorPerson:OWNER]]
role:debitor.debitorRel.anchorPerson:ADMIN[[debitor.debitorRel.anchorPerson:ADMIN]]
role:debitor.debitorRel.anchorPerson:REFERRER[[debitor.debitorRel.anchorPerson:REFERRER]]
end
end
subgraph debitor.debitorRel.holderPerson["`**debitor.debitorRel.holderPerson**`"]
direction TB
style debitor.debitorRel.holderPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph debitor.debitorRel.holderPerson:roles[ ]
style debitor.debitorRel.holderPerson:roles fill:#99bcdb,stroke:white
role:debitor.debitorRel.holderPerson:OWNER[[debitor.debitorRel.holderPerson:OWNER]]
role:debitor.debitorRel.holderPerson:ADMIN[[debitor.debitorRel.holderPerson:ADMIN]]
role:debitor.debitorRel.holderPerson:REFERRER[[debitor.debitorRel.holderPerson:REFERRER]]
end
end
subgraph debitorRel.anchorPerson["`**debitorRel.anchorPerson**`"]
direction TB
style debitorRel.anchorPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph debitorRel.anchorPerson:roles[ ]
style debitorRel.anchorPerson:roles fill:#99bcdb,stroke:white
role:debitorRel.anchorPerson:OWNER[[debitorRel.anchorPerson:OWNER]]
role:debitorRel.anchorPerson:ADMIN[[debitorRel.anchorPerson:ADMIN]]
role:debitorRel.anchorPerson:REFERRER[[debitorRel.anchorPerson:REFERRER]]
end
end
subgraph debitorRel.holderPerson["`**debitorRel.holderPerson**`"]
direction TB
style debitorRel.holderPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph debitorRel.holderPerson:roles[ ]
style debitorRel.holderPerson:roles fill:#99bcdb,stroke:white
role:debitorRel.holderPerson:OWNER[[debitorRel.holderPerson:OWNER]]
role:debitorRel.holderPerson:ADMIN[[debitorRel.holderPerson:ADMIN]]
role:debitorRel.holderPerson:REFERRER[[debitorRel.holderPerson:REFERRER]]
end
end
subgraph debitor.debitorRel["`**debitor.debitorRel**`"]
direction TB
style debitor.debitorRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph debitor.debitorRel:roles[ ]
style debitor.debitorRel:roles fill:#99bcdb,stroke:white
role:debitor.debitorRel:OWNER[[debitor.debitorRel:OWNER]]
role:debitor.debitorRel:ADMIN[[debitor.debitorRel:ADMIN]]
role:debitor.debitorRel:AGENT[[debitor.debitorRel:AGENT]]
role:debitor.debitorRel:TENANT[[debitor.debitorRel:TENANT]]
end
end
subgraph debitor.partnerRel["`**debitor.partnerRel**`"]
direction TB
style debitor.partnerRel fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph debitor.partnerRel:roles[ ]
style debitor.partnerRel:roles fill:#99bcdb,stroke:white
role:debitor.partnerRel:OWNER[[debitor.partnerRel:OWNER]]
role:debitor.partnerRel:ADMIN[[debitor.partnerRel:ADMIN]]
role:debitor.partnerRel:AGENT[[debitor.partnerRel:AGENT]]
role:debitor.partnerRel:TENANT[[debitor.partnerRel:TENANT]]
end
end
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: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 debitor.partnerRel.contact["`**debitor.partnerRel.contact**`"]
direction TB
style debitor.partnerRel.contact fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph debitor.partnerRel.contact:roles[ ]
style debitor.partnerRel.contact:roles fill:#99bcdb,stroke:white
role:debitor.partnerRel.contact:OWNER[[debitor.partnerRel.contact:OWNER]]
role:debitor.partnerRel.contact:ADMIN[[debitor.partnerRel.contact:ADMIN]]
role:debitor.partnerRel.contact:REFERRER[[debitor.partnerRel.contact:REFERRER]]
end
end
subgraph debitor.partnerRel.holderPerson["`**debitor.partnerRel.holderPerson**`"]
direction TB
style debitor.partnerRel.holderPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph debitor.partnerRel.holderPerson:roles[ ]
style debitor.partnerRel.holderPerson:roles fill:#99bcdb,stroke:white
role:debitor.partnerRel.holderPerson:OWNER[[debitor.partnerRel.holderPerson:OWNER]]
role:debitor.partnerRel.holderPerson:ADMIN[[debitor.partnerRel.holderPerson:ADMIN]]
role:debitor.partnerRel.holderPerson:REFERRER[[debitor.partnerRel.holderPerson:REFERRER]]
end
end
subgraph debitor["`**debitor**`"]
direction TB
style debitor fill:#99bcdb,stroke:#274d6e,stroke-width:8px
end
subgraph debitor.refundBankAccount["`**debitor.refundBankAccount**`"]
direction TB
style debitor.refundBankAccount fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph debitor.refundBankAccount:roles[ ]
style debitor.refundBankAccount:roles fill:#99bcdb,stroke:white
role:debitor.refundBankAccount:OWNER[[debitor.refundBankAccount:OWNER]]
role:debitor.refundBankAccount:ADMIN[[debitor.refundBankAccount:ADMIN]]
role:debitor.refundBankAccount:REFERRER[[debitor.refundBankAccount:REFERRER]]
end
end
subgraph debitor.partnerRel.anchorPerson["`**debitor.partnerRel.anchorPerson**`"]
direction TB
style debitor.partnerRel.anchorPerson fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph debitor.partnerRel.anchorPerson:roles[ ]
style debitor.partnerRel.anchorPerson:roles fill:#99bcdb,stroke:white
role:debitor.partnerRel.anchorPerson:OWNER[[debitor.partnerRel.anchorPerson:OWNER]]
role:debitor.partnerRel.anchorPerson:ADMIN[[debitor.partnerRel.anchorPerson:ADMIN]]
role:debitor.partnerRel.anchorPerson:REFERRER[[debitor.partnerRel.anchorPerson:REFERRER]]
end
end
subgraph debitorRel.contact["`**debitorRel.contact**`"]
direction TB
style debitorRel.contact fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph debitorRel.contact:roles[ ]
style debitorRel.contact:roles fill:#99bcdb,stroke:white
role:debitorRel.contact:OWNER[[debitorRel.contact:OWNER]]
role:debitorRel.contact:ADMIN[[debitorRel.contact:ADMIN]]
role:debitorRel.contact:REFERRER[[debitorRel.contact:REFERRER]]
end
end
subgraph debitor.debitorRel.contact["`**debitor.debitorRel.contact**`"]
direction TB
style debitor.debitorRel.contact fill:#99bcdb,stroke:#274d6e,stroke-width:8px
subgraph debitor.debitorRel.contact:roles[ ]
style debitor.debitorRel.contact:roles fill:#99bcdb,stroke:white
role:debitor.debitorRel.contact:OWNER[[debitor.debitorRel.contact:OWNER]]
role:debitor.debitorRel.contact:ADMIN[[debitor.debitorRel.contact:ADMIN]]
role:debitor.debitorRel.contact:REFERRER[[debitor.debitorRel.contact:REFERRER]]
end
end
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
%% granting roles to roles
role:global:ADMIN -.-> role:debitor.debitorRel.anchorPerson:OWNER
role:debitor.debitorRel.anchorPerson:OWNER -.-> role:debitor.debitorRel.anchorPerson:ADMIN
role:debitor.debitorRel.anchorPerson:ADMIN -.-> role:debitor.debitorRel.anchorPerson:REFERRER
role:global:ADMIN -.-> role:debitor.debitorRel.holderPerson:OWNER
role:debitor.debitorRel.holderPerson:OWNER -.-> role:debitor.debitorRel.holderPerson:ADMIN
role:debitor.debitorRel.holderPerson:ADMIN -.-> role:debitor.debitorRel.holderPerson:REFERRER
role:global:ADMIN -.-> role:debitor.debitorRel.contact:OWNER
role:debitor.debitorRel.contact:OWNER -.-> role:debitor.debitorRel.contact:ADMIN
role:debitor.debitorRel.contact:ADMIN -.-> role:debitor.debitorRel.contact:REFERRER
role:global:ADMIN -.-> role:debitor.debitorRel:OWNER
role:debitor.debitorRel:OWNER -.-> role:debitor.debitorRel:ADMIN
role:debitor.debitorRel:ADMIN -.-> role:debitor.debitorRel:AGENT
role:debitor.debitorRel:AGENT -.-> role:debitor.debitorRel:TENANT
role:debitor.debitorRel.contact:ADMIN -.-> role:debitor.debitorRel:TENANT
role:debitor.debitorRel:TENANT -.-> role:debitor.debitorRel.anchorPerson:REFERRER
role:debitor.debitorRel:TENANT -.-> role:debitor.debitorRel.holderPerson:REFERRER
role:debitor.debitorRel:TENANT -.-> role:debitor.debitorRel.contact:REFERRER
role:debitor.debitorRel.anchorPerson:ADMIN -.-> role:debitor.debitorRel:OWNER
role:debitor.debitorRel.holderPerson:ADMIN -.-> role:debitor.debitorRel:AGENT
role:global:ADMIN -.-> role:debitor.refundBankAccount:OWNER
role:debitor.refundBankAccount:OWNER -.-> role:debitor.refundBankAccount:ADMIN
role:debitor.refundBankAccount:ADMIN -.-> role:debitor.refundBankAccount:REFERRER
role:debitor.refundBankAccount:ADMIN -.-> role:debitor.debitorRel:AGENT
role:debitor.debitorRel:AGENT -.-> role:debitor.refundBankAccount:REFERRER
role:global:ADMIN -.-> role:debitor.partnerRel.anchorPerson:OWNER
role:debitor.partnerRel.anchorPerson:OWNER -.-> role:debitor.partnerRel.anchorPerson:ADMIN
role:debitor.partnerRel.anchorPerson:ADMIN -.-> role:debitor.partnerRel.anchorPerson:REFERRER
role:global:ADMIN -.-> role:debitor.partnerRel.holderPerson:OWNER
role:debitor.partnerRel.holderPerson:OWNER -.-> role:debitor.partnerRel.holderPerson:ADMIN
role:debitor.partnerRel.holderPerson:ADMIN -.-> role:debitor.partnerRel.holderPerson:REFERRER
role:global:ADMIN -.-> role:debitor.partnerRel.contact:OWNER
role:debitor.partnerRel.contact:OWNER -.-> role:debitor.partnerRel.contact:ADMIN
role:debitor.partnerRel.contact:ADMIN -.-> role:debitor.partnerRel.contact:REFERRER
role:global:ADMIN -.-> role:debitor.partnerRel:OWNER
role:debitor.partnerRel:OWNER -.-> role:debitor.partnerRel:ADMIN
role:debitor.partnerRel:ADMIN -.-> role:debitor.partnerRel:AGENT
role:debitor.partnerRel:AGENT -.-> role:debitor.partnerRel:TENANT
role:debitor.partnerRel.contact:ADMIN -.-> role:debitor.partnerRel:TENANT
role:debitor.partnerRel:TENANT -.-> role:debitor.partnerRel.anchorPerson:REFERRER
role:debitor.partnerRel:TENANT -.-> role:debitor.partnerRel.holderPerson:REFERRER
role:debitor.partnerRel:TENANT -.-> role:debitor.partnerRel.contact:REFERRER
role:debitor.partnerRel.anchorPerson:ADMIN -.-> role:debitor.partnerRel:OWNER
role:debitor.partnerRel.holderPerson:ADMIN -.-> role:debitor.partnerRel:AGENT
role:debitor.partnerRel:ADMIN -.-> role:debitor.debitorRel:ADMIN
role:debitor.partnerRel:AGENT -.-> role:debitor.debitorRel:AGENT
role:debitor.debitorRel:AGENT -.-> role:debitor.partnerRel:TENANT
role:global:ADMIN -.-> role:debitorRel.anchorPerson:OWNER
role:debitorRel.anchorPerson:OWNER -.-> role:debitorRel.anchorPerson:ADMIN
role:debitorRel.anchorPerson:ADMIN -.-> role:debitorRel.anchorPerson:REFERRER
role:global:ADMIN -.-> role:debitorRel.holderPerson:OWNER
role:debitorRel.holderPerson:OWNER -.-> role:debitorRel.holderPerson:ADMIN
role:debitorRel.holderPerson:ADMIN -.-> role:debitorRel.holderPerson:REFERRER
role:global:ADMIN -.-> role:debitorRel.contact:OWNER
role:debitorRel.contact:OWNER -.-> role:debitorRel.contact:ADMIN
role:debitorRel.contact:ADMIN -.-> role:debitorRel.contact:REFERRER
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.contact:ADMIN -.-> role:debitorRel:TENANT
role:debitorRel:TENANT -.-> role:debitorRel.anchorPerson:REFERRER
role:debitorRel:TENANT -.-> role:debitorRel.holderPerson:REFERRER
role:debitorRel:TENANT -.-> role:debitorRel.contact:REFERRER
role:debitorRel.anchorPerson:ADMIN -.-> role:debitorRel:OWNER
role:debitorRel.holderPerson:ADMIN -.-> role:debitorRel:AGENT
role:debitorRel:AGENT ==> role:bookingItem:OWNER
role:bookingItem:OWNER ==> role:bookingItem:ADMIN
role:bookingItem:ADMIN ==> role:bookingItem:TENANT
role:bookingItem:TENANT ==> role:debitorRel:TENANT
%% granting permissions to roles
role:debitorRel:ADMIN ==> perm:bookingItem:INSERT
role:global:ADMIN ==> perm:bookingItem:DELETE
role:bookingItem:OWNER ==> perm:bookingItem:UPDATE
role:bookingItem:TENANT ==> perm:bookingItem:SELECT
```

View File

@ -0,0 +1,199 @@
--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
newDebitor hs_office_debitor;
newDebitorRel hs_office_relation;
begin
call enterTriggerForObjectUuid(NEW.uuid);
SELECT * FROM hs_office_debitor WHERE uuid = NEW.debitorUuid INTO newDebitor;
assert newDebitor.uuid is not null, format('newDebitor must not be null for NEW.debitorUuid = %s', NEW.debitorUuid);
SELECT debitorRel.*
FROM hs_office_relation debitorRel
JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid
WHERE debitor.uuid = NEW.debitorUuid
INTO newDebitorRel;
assert newDebitorRel.uuid is not null, format('newDebitorRel must not be null for NEW.debitorUuid = %s', NEW.debitorUuid);
perform createRoleWithGrants(
hsBookingItemOWNER(NEW),
permissions => array['UPDATE'],
incomingSuperRoles => array[hsOfficeRelationAGENT(newDebitorRel)]
);
perform createRoleWithGrants(
hsBookingItemADMIN(NEW),
incomingSuperRoles => array[hsBookingItemOWNER(NEW)]
);
perform createRoleWithGrants(
hsBookingItemTENANT(NEW),
permissions => array['SELECT'],
incomingSuperRoles => array[hsBookingItemADMIN(NEW)],
outgoingSubRoles => array[hsOfficeRelationTENANT(newDebitorRel)]
);
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-INSERT:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates INSERT INTO hs_booking_item permissions for the related hs_office_relation rows.
*/
do language plpgsql $$
declare
row hs_office_relation;
begin
call defineContext('create INSERT INTO hs_booking_item permissions for the related hs_office_relation rows');
FOR row IN SELECT * FROM hs_office_relation
WHERE type in ('DEBITOR') -- TODO.rbac: currently manually patched, needs to be generated
LOOP
call grantPermissionToRole(
createPermission(row.uuid, 'INSERT', 'hs_booking_item'),
hsOfficeRelationADMIN(row));
END LOOP;
END;
$$;
/**
Adds hs_booking_item INSERT permission to specified role of new hs_office_relation rows.
*/
create or replace function hs_booking_item_hs_office_relation_insert_tf()
returns trigger
language plpgsql
strict as $$
begin
if NEW.type = 'DEBITOR' then -- TODO.rbac: currently manually patched, needs to be generated
call grantPermissionToRole(
createPermission(NEW.uuid, 'INSERT', 'hs_booking_item'),
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_hs_booking_item_hs_office_relation_insert_tg
after insert on hs_office_relation
for each row
execute procedure hs_booking_item_hs_office_relation_insert_tf();
/**
Checks if the user or assumed roles are allowed to insert a row to hs_booking_item,
where the check is performed by an indirect role.
An indirect role is a role which depends on an object uuid which is not a direct foreign key
of the source entity, but needs to be fetched via joined tables.
*/
create or replace function hs_booking_item_insert_permission_check_tf()
returns trigger
language plpgsql as $$
declare
superRoleObjectUuid uuid;
begin
superRoleObjectUuid := (SELECT debitorRel.uuid
FROM hs_office_relation debitorRel
JOIN hs_office_debitor debitor ON debitor.debitorRelUuid = debitorRel.uuid
WHERE debitor.uuid = NEW.debitorUuid
);
assert superRoleObjectUuid is not null, 'superRoleObjectUuid must not be null';
if ( not hasInsertPermission(superRoleObjectUuid, 'INSERT', 'hs_booking_item') ) then
raise exception
'[403] insert into hs_booking_item not allowed for current subjects % (%)',
currentSubjects(), currentSubjectsUuids();
end if;
return NEW;
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 generateRbacIdentityViewFromQuery('hs_booking_item',
$idName$
SELECT i.uuid as uuid, d.idName || ':' || i.caption as idName
FROM hs_booking_item i
JOIN hs_office_debitor_iv d ON d.uuid = i.debitorUuid
$idName$);
--//
-- ============================================================================
--changeset hs-booking-item-rbac-RESTRICTED-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRbacRestrictedView('hs_booking_item',
$orderBy$
validity
$orderBy$,
$updates$
version = new.version,
validity = new.validity,
resources = new.resources
$updates$);
--//

View File

@ -0,0 +1,52 @@
--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;
relatedDebitor hs_office_debitor;
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 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;
raise notice 'creating test booking-item: %', givenPartnerNumber::text || givenDebitorSuffix::text;
raise notice '- using debitor (%): %', relatedDebitor.uuid, relatedDebitor;
insert
into hs_booking_item (uuid, debitoruuid, caption, validity, resources)
values (uuid_generate_v4(), relatedDebitor.uuid, 'some ManagedServer', daterange('20221001', null, '[]'), '{ "CPU": 2, "SDD": 512, "extra": 42 }'::jsonb),
(uuid_generate_v4(), relatedDebitor.uuid, 'some CloudServer', daterange('20230115', '20240415', '[)'), '{ "CPU": 2, "HDD": 1024, "extra": 42 }'::jsonb),
(uuid_generate_v4(), relatedDebitor.uuid, 'some Whatever', daterange('20240401', null, '[]'), '{ "CPU": 1, "SDD": 512, "HDD": 2048, "extra": 42 }'::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;
$$;

View File

@ -127,3 +127,9 @@ databaseChangeLog:
file: db/changelog/5-hs-office/512-coopassets/5126-hs-office-coopassets-migration.sql file: db/changelog/5-hs-office/512-coopassets/5126-hs-office-coopassets-migration.sql
- include: - include:
file: db/changelog/5-hs-office/512-coopassets/5128-hs-office-coopassets-test-data.sql 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
- include:
file: db/changelog/6-hs-booking/601-booking-item/6013-hs-booking-item-rbac.sql
- include:
file: db/changelog/6-hs-booking/601-booking-item/6018-hs-booking-item-test-data.sql

View File

@ -50,6 +50,7 @@ public class ArchitectureTest {
"..hs.office.person", "..hs.office.person",
"..hs.office.relation", "..hs.office.relation",
"..hs.office.sepamandate", "..hs.office.sepamandate",
"..hs.booking.item",
"..errors", "..errors",
"..mapper", "..mapper",
"..ping", "..ping",
@ -123,11 +124,22 @@ public class ArchitectureTest {
@ArchTest @ArchTest
@SuppressWarnings("unused") @SuppressWarnings("unused")
public static final ArchRule hsAdminPackagesRule = classes() public static final ArchRule hsOfficePackageAccessRule = classes()
.that().resideInAPackage("..hs.office.(*)..") .that().resideInAPackage("..hs.office.(*)..")
.should().onlyBeAccessed().byClassesThat() .should().onlyBeAccessed().byClassesThat()
.resideInAnyPackage( .resideInAnyPackage(
"..hs.office.(*)..", "..hs.office.(*)..",
"..hs.booking.(*)..",
"..rbac.rbacgrant" // TODO.test: just because of RbacGrantsDiagramServiceIntegrationTest
);
@ArchTest
@SuppressWarnings("unused")
public static final ArchRule hsBookingPackageAccessRule = classes()
.that().resideInAPackage("..hs.booking.(*)..")
.should().onlyBeAccessed().byClassesThat()
.resideInAnyPackage(
"..hs.booking.(*)..",
"..rbac.rbacgrant" // TODO.test: just because of RbacGrantsDiagramServiceIntegrationTest "..rbac.rbacgrant" // TODO.test: just because of RbacGrantsDiagramServiceIntegrationTest
); );

View File

@ -0,0 +1,340 @@
package net.hostsharing.hsadminng.hs.booking.item;
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.office.debitor.HsOfficeDebitorRepository;
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.time.LocalDate;
import java.util.Map;
import java.util.UUID;
import static java.util.Map.entry;
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 HsBookingItemControllerAcceptanceTest extends ContextBasedTestWithCleanup {
@LocalServerPort
private Integer port;
@Autowired
HsBookingItemRepository bookingItemRepo;
@Autowired
HsOfficeDebitorRepository debitorRepo;
@Autowired
JpaAttempt jpaAttempt;
@PersistenceContext
EntityManager em;
@Nested
class ListBookingItems {
@Test
void globalAdmin_canViewAllBookingItemsOfArbitraryDebitor() {
// given
context("superuser-alex@hostsharing.net");
final var givenDebitor = debitorRepo.findDebitorByDebitorNumber(1000111).get(0);
RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.port(port)
.when()
.get("http://localhost/api/hs/booking/items?debitorUuid=" + givenDebitor.getUuid())
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("", lenientlyEquals("""
[
{
"caption": "some ManagedServer",
"validFrom": "2022-10-01",
"validTo": null,
"resources": {
"CPU": 2,
"SDD": 512,
"extra": 42
}
},
{
"caption": "some CloudServer",
"validFrom": "2023-01-15",
"validTo": "2024-04-14",
"resources": {
"CPU": 2,
"HDD": 1024,
"extra": 42
}
},
{
"caption": "some Whatever",
"validFrom": "2024-04-01",
"validTo": null,
"resources": {
"CPU": 1,
"HDD": 2048,
"SDD": 512,
"extra": 42
}
}
]
"""));
// @formatter:on
}
}
@Nested
class AddBookingItem {
@Test
void globalAdmin_canAddBookingItem() {
context.define("superuser-alex@hostsharing.net");
final var givenDebitor = debitorRepo.findDebitorByDebitorNumber(1000111).get(0);
final var location = RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"debitorUuid": "%s",
"caption": "some new booking",
"resources": { "CPU": 12, "extra": 42 },
"validFrom": "2022-10-13"
}
""".formatted(givenDebitor.getUuid()))
.port(port)
.when()
.post("http://localhost/api/hs/booking/items")
.then().log().all().assertThat()
.statusCode(201)
.contentType(ContentType.JSON)
.body("", lenientlyEquals("""
{
"caption": "some new booking",
"validFrom": "2022-10-13",
"validTo": null,
"resources": { "CPU": 12 }
}
"""))
.header("Location", matchesRegex("http://localhost:[1-9][0-9]*/api/hs/booking/items/[^/]*"))
.extract().header("Location"); // @formatter:on
// finally, the new bookingItem can be accessed under the generated UUID
final var newUserUuid = UUID.fromString(
location.substring(location.lastIndexOf('/') + 1));
assertThat(newUserUuid).isNotNull();
}
}
@Nested
class GetBookingItem {
@Test
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();
RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.port(port)
.when()
.get("http://localhost/api/hs/booking/items/" + givenBookingItemUuid)
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("", lenientlyEquals("""
{
"caption": "some CloudServer",
"validFrom": "2023-01-15",
"validTo": "2024-04-14",
"resources": { CPU: 2, HDD: 1024 }
}
""")); // @formatter:on
}
@Test
void normalUser_canNotGetUnrelatedBookingItem() {
context.define("superuser-alex@hostsharing.net");
final var givenBookingItemUuid = bookingItemRepo.findAll().stream()
.filter(bi -> bi.getDebitor().getDebitorNumber() == 1000212)
.map(HsBookingItemEntity::getUuid)
.findAny().orElseThrow();
RestAssured // @formatter:off
.given()
.header("current-user", "selfregistered-user-drew@hostsharing.org")
.port(port)
.when()
.get("http://localhost/api/hs/booking/items/" + givenBookingItemUuid)
.then().log().body().assertThat()
.statusCode(404); // @formatter:on
}
@Test
void debitorAgentUser_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();
RestAssured // @formatter:off
.given()
.header("current-user", "person-TuckerJack@example.com")
.port(port)
.when()
.get("http://localhost/api/hs/booking/items/" + givenBookingItemUuid)
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("", lenientlyEquals("""
{
"caption": "some CloudServer",
"validFrom": "2023-01-15",
"validTo": "2024-04-14",
"resources": { CPU: 2, HDD: 1024 }
}
""")); // @formatter:on
}
}
@Nested
class PatchBookingItem {
@Test
void globalAdmin_canPatchAllUpdatablePropertiesOfBookingItem() {
final var givenBookingItem = givenSomeTemporaryBookingItemForDebitorNumber(1000111, entry("something", 1));
RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"validFrom": "2020-06-05",
"validTo": "2022-12-31",
"resources": {
"CPU": "4",
"HDD": null,
"SSD": "4096"
}
}
""")
.port(port)
.when()
.patch("http://localhost/api/hs/booking/items/" + givenBookingItem.getUuid())
.then().log().all().assertThat()
.statusCode(200)
.contentType(ContentType.JSON)
.body("", lenientlyEquals("""
{
"caption": "some test-booking",
"validFrom": "2022-11-01",
"validTo": "2022-12-31",
"resources": {
"CPU": "4",
"SSD": "4096",
"something": 1
}
}
""")); // @formatter:on
// finally, the bookingItem is actually updated
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.getValidFrom()).isEqualTo("2020-06-05");
assertThat(mandate.getValidTo()).isEqualTo("2022-12-31");
return true;
});
}
}
@Nested
class DeleteBookingItem {
@Test
void globalAdmin_canDeleteArbitraryBookingItem() {
context.define("superuser-alex@hostsharing.net");
final var givenBookingItem = givenSomeTemporaryBookingItemForDebitorNumber(1000111, entry("something", 1));
RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.port(port)
.when()
.delete("http://localhost/api/hs/booking/items/" + givenBookingItem.getUuid())
.then().log().body().assertThat()
.statusCode(204); // @formatter:on
// then the given bookingItem is gone
assertThat(bookingItemRepo.findByUuid(givenBookingItem.getUuid())).isEmpty();
}
@Test
void normalUser_canNotDeleteUnrelatedBookingItem() {
context.define("superuser-alex@hostsharing.net");
final var givenBookingItem = givenSomeTemporaryBookingItemForDebitorNumber(1000111, entry("something", 1));
RestAssured // @formatter:off
.given()
.header("current-user", "selfregistered-user-drew@hostsharing.org")
.port(port)
.when()
.delete("http://localhost/api/hs/booking/items/" + givenBookingItem.getUuid())
.then().log().body().assertThat()
.statusCode(404); // @formatter:on
// then the given bookingItem is still there
assertThat(bookingItemRepo.findByUuid(givenBookingItem.getUuid())).isNotEmpty();
}
}
private HsBookingItemEntity givenSomeTemporaryBookingItemForDebitorNumber(final int debitorNumber,
final Map.Entry<String, Integer> resources) {
return jpaAttempt.transacted(() -> {
context.define("superuser-alex@hostsharing.net");
final var givenDebitor = debitorRepo.findDebitorByDebitorNumber(debitorNumber).get(0);
final var newBookingItem = HsBookingItemEntity.builder()
.uuid(UUID.randomUUID())
.debitor(givenDebitor)
.caption("some test-booking")
.resources(Map.ofEntries(resources))
.validity(Range.closedOpen(
LocalDate.parse("2022-11-01"), LocalDate.parse("2023-03-31")))
.build();
return bookingItemRepo.save(newBookingItem);
}).assertSuccessful().returnedValue();
}
}

View File

@ -0,0 +1,112 @@
package net.hostsharing.hsadminng.hs.booking.item;
import io.hypersistence.utils.hibernate.type.range.Range;
import net.hostsharing.hsadminng.hs.booking.generated.api.v1.model.HsBookingItemPatchResource;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity;
import net.hostsharing.hsadminng.mapper.KeyValueMap;
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.time.LocalDate;
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.mapper.PatchMap.entry;
import static net.hostsharing.hsadminng.mapper.PatchMap.patchMap;
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 HsBookingItemEntityPatcherUnitTest extends PatchUnitTestBase<
HsBookingItemPatchResource,
HsBookingItemEntity
> {
private static final UUID INITIAL_BOOKING_ITEM_UUID = UUID.randomUUID();
private static final LocalDate GIVEN_VALID_FROM = LocalDate.parse("2020-04-15");
private static final LocalDate PATCHED_VALID_TO = LocalDate.parse("2022-12-31");
private static final Map<String, Object> INITIAL_RESOURCES = patchMap(
entry("CPU", 1),
entry("HDD", 1024),
entry("MEM", 64)
);
private static final Map<String, Object> PATCH_RESOURCES = patchMap(
entry("CPU", 2),
entry("HDD", null),
entry("SDD", 256)
);
private static final Map<String, Object> PATCHED_RESOURCES = patchMap(
entry("CPU", 2),
entry("SDD", 256),
entry("MEM", 64)
);
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(HsBookingItemEntity.class), any())).thenAnswer(invocation ->
HsBookingItemEntity.builder().uuid(invocation.getArgument(1)).build());
}
@Override
protected HsBookingItemEntity newInitialEntity() {
final var entity = new HsBookingItemEntity();
entity.setUuid(INITIAL_BOOKING_ITEM_UUID);
entity.setDebitor(TEST_DEBITOR);
entity.getResources().putAll(KeyValueMap.from(INITIAL_RESOURCES));
entity.setCaption(INITIAL_CAPTION);
entity.setValidity(Range.closedInfinite(GIVEN_VALID_FROM));
return entity;
}
@Override
protected HsBookingItemPatchResource newPatchResource() {
return new HsBookingItemPatchResource();
}
@Override
protected HsBookingItemEntityPatcher createPatcher(final HsBookingItemEntity bookingItem) {
return new HsBookingItemEntityPatcher(bookingItem);
}
@Override
protected Stream<Property> propertyTestDescriptors() {
return Stream.of(
new JsonNullableProperty<>(
"caption",
HsBookingItemPatchResource::setCaption,
PATCHED_CAPTION,
HsBookingItemEntity::setCaption),
new SimpleProperty<>(
"resources",
HsBookingItemPatchResource::setResources,
PATCH_RESOURCES,
HsBookingItemEntity::putResources,
PATCHED_RESOURCES)
.notNullable(),
new JsonNullableProperty<>(
"validto",
HsBookingItemPatchResource::setValidTo,
PATCHED_VALID_TO,
HsBookingItemEntity::setValidTo)
);
}
}

View File

@ -0,0 +1,56 @@
package net.hostsharing.hsadminng.hs.booking.item;
import org.junit.jupiter.api.Test;
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.mapper.PostgresDateRange.toPostgresDateRange;
import static org.assertj.core.api.Assertions.assertThat;
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");
final HsBookingItemEntity givenBookingItem = HsBookingItemEntity.builder()
.debitor(TEST_DEBITOR)
.caption("some caption")
.resources(Map.ofEntries(
entry("CPUs", 2),
entry("SSD-storage", 512),
entry("HDD-storage", 2048)))
.validity(toPostgresDateRange(GIVEN_VALID_FROM, GIVEN_VALID_TO))
.build();
@Test
void toStringContainsAllPropertiesAndResourcesSortedByKey() {
final var result = givenBookingItem.toString();
assertThat(result).isEqualTo("HsBookingItemEntity(D-1000100, [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");
}
@Test
void settingValidFromKeepsValidTo() {
givenBookingItem.setValidFrom(LocalDate.parse("2023-12-31"));
assertThat(givenBookingItem.getValidFrom()).isEqualTo(LocalDate.parse("2023-12-31"));
assertThat(givenBookingItem.getValidTo()).isEqualTo(GIVEN_VALID_TO);
}
@Test
void settingValidToKeepsValidFrom() {
givenBookingItem.setValidTo(LocalDate.parse("2024-12-31"));
assertThat(givenBookingItem.getValidFrom()).isEqualTo(GIVEN_VALID_FROM);
assertThat(givenBookingItem.getValidTo()).isEqualTo(LocalDate.parse("2024-12-31"));
}
}

View File

@ -0,0 +1,337 @@
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.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.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.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static java.util.Map.entry;
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.rbac.test.JpaAttempt.attempt;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@Import({ Context.class, JpaAttempt.class })
class HsBookingItemRepositoryIntegrationTest extends ContextBasedTestWithCleanup {
@Autowired
HsBookingItemRepository bookingItemRepo;
@Autowired
HsOfficeDebitorRepository debitorRepo;
@Autowired
RawRbacRoleRepository rawRoleRepo;
@Autowired
RawRbacGrantRepository rawGrantRepo;
@Autowired
JpaAttempt jpaAttempt;
@PersistenceContext
EntityManager em;
@MockBean
HttpServletRequest request;
@Nested
class CreateBookingItem {
@Test
public void testHostsharingAdmin_withoutAssumedRole_canCreateNewBookingItem() {
// given
context("superuser-alex@hostsharing.net");
final var count = bookingItemRepo.count();
final var givenDebitor = debitorRepo.findDebitorByOptionalNameLike("First").get(0);
// when
final var result = attempt(em, () -> {
final var newBookingItem = HsBookingItemEntity.builder()
.debitor(givenDebitor)
.caption("some new booking item")
.validity(Range.closedOpen(
LocalDate.parse("2020-01-01"), LocalDate.parse("2023-01-01")))
.build();
return toCleanup(bookingItemRepo.save(newBookingItem));
});
// then
result.assertSuccessful();
assertThat(result.returnedValue()).isNotNull().extracting(HsBookingItemEntity::getUuid).isNotNull();
assertThatBookingItemIsPersisted(result.returnedValue());
assertThat(bookingItemRepo.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.findDebitorByOptionalNameLike("First").get(0);
final var newBookingItem = HsBookingItemEntity.builder()
.debitor(givenDebitor)
.caption("some new booking item")
.validity(Range.closedOpen(
LocalDate.parse("2020-01-01"), LocalDate.parse("2023-01-01")))
.build();
return toCleanup(bookingItemRepo.save(newBookingItem));
});
// then
final var all = rawRoleRepo.findAll();
assertThat(distinctRoleNamesOf(all)).containsExactlyInAnyOrder(Array.from(
initialRoleNames,
"hs_booking_item#D-1000111:some new booking item:ADMIN",
"hs_booking_item#D-1000111:some new booking item:OWNER",
"hs_booking_item#D-1000111:some new booking item:TENANT"));
assertThat(distinctGrantDisplaysOf(rawGrantRepo.findAll()))
.map(s -> s.replace("hs_office_", ""))
.containsExactlyInAnyOrder(fromFormatted(
initialGrantNames,
// insert+delete
"{ grant perm:hs_booking_item#D-1000111:some new booking item:DELETE to role:global#global:ADMIN by system and assume }",
// owner
"{ grant perm:hs_booking_item#D-1000111:some new booking item:UPDATE to role:hs_booking_item#D-1000111:some new booking item:OWNER by system and assume }",
"{ grant role:hs_booking_item#D-1000111:some new booking item:OWNER to role:relation#FirstGmbH-with-DEBITOR-FirstGmbH:AGENT by system and assume }",
// admin
"{ grant role:hs_booking_item#D-1000111:some new booking item:ADMIN to role:hs_booking_item#D-1000111:some new booking item:OWNER by system and assume }",
// tenant
"{ grant role:hs_booking_item#D-1000111:some new booking item:TENANT to role:hs_booking_item#D-1000111:some new booking item:ADMIN by system and assume }",
"{ grant perm:hs_booking_item#D-1000111:some new booking item:SELECT to role:hs_booking_item#D-1000111:some new booking item:TENANT by system and assume }",
"{ grant role:relation#FirstGmbH-with-DEBITOR-FirstGmbH:TENANT to role:hs_booking_item#D-1000111:some new booking item:TENANT by system and assume }",
null));
}
private void assertThatBookingItemIsPersisted(final HsBookingItemEntity saved) {
final var found = bookingItemRepo.findByUuid(saved.getUuid());
assertThat(found).isNotEmpty().map(HsBookingItemEntity::toString).get().isEqualTo(saved.toString());
}
}
@Nested
class FindByDebitorUuid {
@Test
public void globalAdmin_withoutAssumedRole_canViewAllBookingItemsOfArbitraryDebitor() {
// given
context("superuser-alex@hostsharing.net");
final var debitorUuid = debitorRepo.findDebitorByDebitorNumber(1000212).stream()
.findAny().orElseThrow().getUuid();
// when
final var result = bookingItemRepo.findAllByDebitorUuid(debitorUuid);
// then
allTheseBookingItemsAreReturned(
result,
"HsBookingItemEntity(D-1000212, [2022-10-01,), some ManagedServer, { CPU: 2, SDD: 512, extra: 42 })",
"HsBookingItemEntity(D-1000212, [2023-01-15,2024-04-15), some CloudServer, { CPU: 2, HDD: 1024, extra: 42 })",
"HsBookingItemEntity(D-1000212, [2024-04-01,), some Whatever, { CPU: 1, HDD: 2048, SDD: 512, extra: 42 })");
}
@Test
public void normalUser_canViewOnlyRelatedBookingItems() {
// given:
context("person-FirbySusan@example.com");
final var debitorUuid = debitorRepo.findDebitorByDebitorNumber(1000111).stream().findAny().orElseThrow().getUuid();
// when:
final var result = bookingItemRepo.findAllByDebitorUuid(debitorUuid);
// then:
exactlyTheseBookingItemsAreReturned(
result,
"HsBookingItemEntity(D-1000111, [2022-10-01,), some ManagedServer, { CPU: 2, SDD: 512, extra: 42 })",
"HsBookingItemEntity(D-1000111, [2023-01-15,2024-04-15), some CloudServer, { CPU: 2, HDD: 1024, extra: 42 })",
"HsBookingItemEntity(D-1000111, [2024-04-01,), some Whatever, { CPU: 1, HDD: 2048, SDD: 512, extra: 42 })");
}
}
@Nested
class UpdateBookingItem {
@Test
public void hostsharingAdmin_canUpdateArbitraryBookingItem() {
// given
final var givenBookingItemUuid = givenSomeTemporaryBookingItem(1000111).getUuid();
// when
final var result = jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net");
final var foundBookingItem = em.find(HsBookingItemEntity.class, givenBookingItemUuid);
foundBookingItem.getResources().put("CPUs", 2);
foundBookingItem.getResources().remove("SSD-storage");
foundBookingItem.getResources().put("HSD-storage", 2048);
foundBookingItem.setValidity(Range.closedOpen(
LocalDate.parse("2019-05-17"), LocalDate.parse("2023-01-01")));
return toCleanup(bookingItemRepo.save(foundBookingItem));
});
// then
result.assertSuccessful();
jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net");
assertThatBookingItemActuallyInDatabase(result.returnedValue());
}).assertSuccessful();
}
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());
}
}
@Nested
class DeleteByUuid {
@Test
public void globalAdmin_withoutAssumedRole_canDeleteAnyBookingItem() {
// given
context("superuser-alex@hostsharing.net", null);
final var givenBookingItem = givenSomeTemporaryBookingItem(1000111);
// when
final var result = jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net");
bookingItemRepo.deleteByUuid(givenBookingItem.getUuid());
});
// then
result.assertSuccessful();
assertThat(jpaAttempt.transacted(() -> {
context("superuser-fran@hostsharing.net", null);
return bookingItemRepo.findByUuid(givenBookingItem.getUuid());
}).assertSuccessful().returnedValue()).isEmpty();
}
@Test
public void nonGlobalAdmin_canNotDeleteTheirRelatedBookingItem() {
// given
context("superuser-alex@hostsharing.net", null);
final var givenBookingItem = givenSomeTemporaryBookingItem(1000111);
// when
final var result = jpaAttempt.transacted(() -> {
context("person-FirbySusan@example.com");
assertThat(bookingItemRepo.findByUuid(givenBookingItem.getUuid())).isPresent();
bookingItemRepo.deleteByUuid(givenBookingItem.getUuid());
});
// then
result.assertExceptionWithRootCauseMessage(
JpaSystemException.class,
"[403] Subject ", " is not allowed to delete hs_booking_item");
assertThat(jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net");
return bookingItemRepo.findByUuid(givenBookingItem.getUuid());
}).assertSuccessful().returnedValue()).isPresent(); // still there
}
@Test
public void deletingABookingItemAlsoDeletesRelatedRolesAndGrants() {
// given
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);
// when
final var result = jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net");
return bookingItemRepo.deleteByUuid(givenBookingItem.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_item';
""");
// when
@SuppressWarnings("unchecked") final List<Object[]> customerLogEntries = query.getResultList();
// then
assertThat(customerLogEntries).map(Arrays::toString).contains(
"[creating booking-item test-data 1000111, hs_booking_item, INSERT]",
"[creating booking-item test-data 1000212, hs_booking_item, INSERT]",
"[creating booking-item test-data 1000313, hs_booking_item, INSERT]");
}
private HsBookingItemEntity givenSomeTemporaryBookingItem(final int debitorNumber) {
return jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net");
final var givenDebitor = debitorRepo.findDebitorByDebitorNumber(debitorNumber).get(0);
final var newBookingItem = HsBookingItemEntity.builder()
.debitor(givenDebitor)
.caption("some temp booking item")
.validity(Range.closedOpen(
LocalDate.parse("2020-01-01"), LocalDate.parse("2023-01-01")))
.resources(Map.ofEntries(
entry("CPUs", 1),
entry("SSD-storage", 256)))
.build();
return toCleanup(bookingItemRepo.save(newBookingItem));
}).assertSuccessful().returnedValue();
}
void exactlyTheseBookingItemsAreReturned(
final List<HsBookingItemEntity> actualResult,
final String... bookingItemNames) {
assertThat(actualResult)
.extracting(bookingItemEntity -> bookingItemEntity.toString())
.containsExactlyInAnyOrder(bookingItemNames);
}
void allTheseBookingItemsAreReturned(final List<HsBookingItemEntity> actualResult, final String... bookingItemNames) {
assertThat(actualResult)
.extracting(bookingItemEntity -> bookingItemEntity.toString())
.contains(bookingItemNames);
}
}

View File

@ -1,10 +1,7 @@
package net.hostsharing.hsadminng.hs.office.coopshares; package net.hostsharing.hsadminng.hs.office.coopshares;
import net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionEntity;
import net.hostsharing.hsadminng.hs.office.coopassets.HsOfficeCoopAssetsTransactionType;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import static net.hostsharing.hsadminng.hs.office.membership.TestHsMembership.TEST_MEMBERSHIP; import static net.hostsharing.hsadminng.hs.office.membership.TestHsMembership.TEST_MEMBERSHIP;

View File

@ -181,6 +181,7 @@ class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTestWithClean
.containsExactlyInAnyOrder(Array.fromFormatted( .containsExactlyInAnyOrder(Array.fromFormatted(
initialGrantNames, 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>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 }",
// owner // owner
"{ grant perm:debitor#D-1000122:DELETE to role:relation#FirstGmbH-with-DEBITOR-FourtheG:OWNER by system and assume }", "{ grant perm:debitor#D-1000122:DELETE to role:relation#FirstGmbH-with-DEBITOR-FourtheG:OWNER by system and assume }",

View File

@ -613,6 +613,7 @@ public class ImportOfficeData extends ContextBasedTest {
private void deleteTestDataFromHsOfficeTables() { private void deleteTestDataFromHsOfficeTables() {
jpaAttempt.transacted(() -> { jpaAttempt.transacted(() -> {
context(rbacSuperuser); context(rbacSuperuser);
em.createNativeQuery("delete from hs_booking_item where true").executeUpdate();
em.createNativeQuery("delete from hs_office_coopassetstransaction 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_coopassetstransaction_legacy_id where true").executeUpdate();
em.createNativeQuery("delete from hs_office_coopsharestransaction where true").executeUpdate(); em.createNativeQuery("delete from hs_office_coopsharestransaction where true").executeUpdate();