adds HsOfficePartner

This commit is contained in:
Michael Hoennig 2022-10-03 11:09:36 +02:00
parent d3312c4444
commit c3195662dd
35 changed files with 2182 additions and 35 deletions

View File

@ -25,3 +25,4 @@ alias pg-sql-reset='pg-sql-stop; pg-sql-remove; pg-sql-run'
alias pg-sql-backup='docker exec -i hsadmin-ng-postgres /usr/bin/pg_dump --clean --create -U postgres postgres | gzip -9'
alias pg-sql-restore='gunzip --stdout | docker exec -i hsadmin-ng-postgres psql -U postgres -d postgres'
alias fp='grep -r '@Accepts' src | sed -e 's/^.*@/@/g' | sort -u | wc -l'

View File

@ -9,6 +9,7 @@ For architecture consider the files in the `doc` and `adr` folder.
- [PostgreSQL Server](#postgresql-server)
- [Markdown](#markdown)
- [Render Markdown embedded PlantUML](#render-markdown-embedded-plantuml)
- [Render Markdown Embedded Mermaid Diagrams](#render-markdown-embedded-mermaid-diagrams)
- [IDE Specific Settings](#ide-specific-settings)
- [IntelliJ IDEA](#intellij-idea)
- [Other Tools](#other-tools)
@ -27,6 +28,7 @@ For architecture consider the files in the `doc` and `adr` folder.
- [Dependency-License-Compatibility](#dependency-license-compatibility)
- [Dependency Version Upgrade](#dependency-version-upgrade)
- [How To ...](#how-to-...)
- [How to Configure .pgpass for the Default PostgreSQL Database?](#how-to-configure-.pgpass-for-the-default-postgresql-database?)
- [How to Run the Tests Against a Local User-Space Podman Daemon?](#how-to-run-the-tests-against-a-local-user-space-podman-daemon?)
- [Install and Run Podman](#install-and-run-podman)
- [Use the Command Line to Run the Tests Against the Podman Daemon ](#use-the-command-line-to-run-the-tests-against-the-podman-daemon-)
@ -35,7 +37,8 @@ For architecture consider the files in the `doc` and `adr` folder.
- [How to Run the Tests Against a Remote Podman or Docker Daemon?](#how-to-run-the-tests-against-a-remote-podman-or-docker-daemon?)
- [How to Run the Application on a Different Port?](#how-to-run-the-application-on-a-different-port?)
- [How to Use a Persistent Database for Integration Tests?](#how-to-use-a-persistent-database-for-integration-tests?)
- [How to Amend Liquibase SQL Changesets?](#how-to-amend-liquibase-sql-changesets?)
- [How to Amend Liquibase SQL Changesets?](#how-to-amend-liquibase-sql-changesets?)
- [How to Re-Generate Spring-Controller-Interfaces from OpenAPI specs?](#how-to-re-generate-spring-controller-interfaces-from-openapi-specs?)
- [Further Documentation](#further-documentation)
<!-- generated TOC end. -->
@ -190,6 +193,12 @@ Again, given the container is running, to restore the backup from ~/backup, run:
To generate the TOC (Table of Contents), a little bash script from a
[Blog Article](https://medium.com/@acrodriguez/one-liner-to-generate-a-markdown-toc-f5292112fd14) was used.
Given this is in PATH as `md-toc`, use:
```shell
md-toc <README.md 2 4 | sed -e 's/^ //g'
```
To render the Markdown files, especially to watch embedded PlantUML diagrams, you can use one of the following methods:
#### Render Markdown embedded PlantUML
@ -233,6 +242,39 @@ pandoc --filter pandoc-plantuml rbac.md -o rbac.pdf
If you have figured out how it works, please add instructions above this section.
#### Render Markdown Embedded Mermaid Diagrams
The source of RBAC role diagrams are much easier to read with Mermaid than with PlantUML or GraphViz, that's the main reason Mermaid ist used too.
Can you see the following diagram right in your IDE?
I mean a real graphic diagram, not just some markup code.
@startuml
me -> you: Can you see this diagram?
you -> me: Sorry, I don't :-(
me -> you: Install some tooling!
@enduml
```mermaid
graph TD;
A[Can you see this diagram?];
A --> yes;
A --> no;
no --> F[Follow the instructions below!]
F --> yes
yes --> E[Then everything is fine.]
```
If not, you need to install some tooling.
##### for IntelliJ IDEA (or derived products)
You just need the bundled Markdown plugin enabled and install and activate the Mermaid plugin in its [settings](jetbrains://idea/settings?name=Languages+%26+Frameworks--Markdown).
##### for other IDEs / command-line / operating systems
If you have figured out how it works, please add instructions above this section.
### IDE Specific Settings
#### IntelliJ IDEA
@ -643,8 +685,8 @@ If the persistent database and the temporary database show different results, on
2. You might have changes in the database which interfere with the tests,
e.g. from a previous run of tests or manually applied.
It's best to run `pg-sql-reset && gw bootRun` before each test run, to have a clean database.
## How to Amend Liquibase SQL Changesets?
### How to Amend Liquibase SQL Changesets?
Liquibase changesets are meant to be immutable and based on each other.
That means, once a changeset is written, it never changes, not even a whitespace or comment.
@ -668,6 +710,22 @@ gw bootRun
<big>**&#9888;**</big>
Just don't forget switching to the migration mode, once there is a production database!
### How to Re-Generate Spring-Controller-Interfaces from OpenAPI specs?
The API is described as OpenAPI specifications in `src/main/resources/api-definition/`.
Once generated, the interfaces for the Spring-Controllers can be found in `build/generated/sources/openapi`.
These interfaces have to be implemented by subclasses named `*Controller`.
All gradle tasks which need the generated interfaces depend on the Gradle task `processSpring` which controls the code generation.
It can also be executed directly:
```shell
gw processSpring
```
## Further Documentation
- the `doc` directory contains architecture concepts and a glossary

View File

@ -0,0 +1,12 @@
package net.hostsharing.hsadminng.errors;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface DisplayName {
String value() default "";
}

View File

@ -6,15 +6,18 @@ import org.springframework.core.NestedExceptionUtils;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.orm.jpa.JpaObjectRetrievalFailureException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import javax.persistence.EntityNotFoundException;
import java.time.LocalDateTime;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.regex.Pattern;
@ControllerAdvice
public class RestResponseEntityExceptionHandler
@ -42,6 +45,41 @@ public class RestResponseEntityExceptionHandler
return errorResponse(request, HttpStatus.NOT_FOUND, message);
}
@ExceptionHandler({ JpaObjectRetrievalFailureException.class, EntityNotFoundException.class })
protected ResponseEntity<CustomErrorResponse> handleJpaObjectRetrievalFailureException(
final RuntimeException exc, final WebRequest request) {
final var message =
userReadableEntityClassName(
firstLine(NestedExceptionUtils.getMostSpecificCause(exc).getMessage()));
return errorResponse(request, HttpStatus.BAD_REQUEST, message);
}
private String userReadableEntityClassName(final String exceptionMessage) {
final var regex = "(net.hostsharing.hsadminng.[a-z0-9_.]*.[A-Za-z0-9_$]*Entity) ";
final var pattern = Pattern.compile(regex);
final var matcher = pattern.matcher(exceptionMessage);
if (matcher.find()) {
final var entityName = matcher.group(1);
final var entityClass = resolveClassOrNull(entityName);
if (entityClass != null ) {
return (entityClass.isAnnotationPresent(DisplayName.class)
? exceptionMessage.replace(entityName, entityClass.getAnnotation(DisplayName.class).value())
: exceptionMessage.replace(entityName, entityClass.getSimpleName()))
.replace(" with id ", " with uuid ");
}
}
return exceptionMessage;
}
private static Class<?> resolveClassOrNull(final String entityName) {
try {
return ClassLoader.getSystemClassLoader().loadClass(entityName);
} catch (ClassNotFoundException e) {
return null;
}
}
@ExceptionHandler(Throwable.class)
protected ResponseEntity<CustomErrorResponse> handleOtherExceptions(
final Throwable exc, final WebRequest request) {

View File

@ -2,6 +2,7 @@ package net.hostsharing.hsadminng.hs.office.contact;
import lombok.*;
import lombok.experimental.FieldNameConstants;
import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.Stringify;
import net.hostsharing.hsadminng.Stringifyable;
@ -21,6 +22,7 @@ import static net.hostsharing.hsadminng.Stringify.stringify;
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
@DisplayName("Contact")
public class HsOfficeContactEntity implements Stringifyable {
private static Stringify<HsOfficeContactEntity> toString = stringify(HsOfficeContactEntity.class, "contact")

View File

@ -0,0 +1,150 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import net.hostsharing.hsadminng.Mapper;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.api.HsOfficeDebitorsApi;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.*;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntity;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorEntityPatcher;
import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository;
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 javax.persistence.EntityManager;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
import java.util.function.BiConsumer;
import static net.hostsharing.hsadminng.Mapper.map;
@RestController
public class HsOfficeDebitorController implements HsOfficeDebitorsApi {
@Autowired
private Context context;
@Autowired
private HsOfficeDebitorRepository debitorRepo;
@Autowired
private HsOfficePartnerRepository partnerRepo;
@Autowired
private HsOfficeContactRepository contactRepo;
@Autowired
private EntityManager em;
@Override
@Transactional(readOnly = true)
public ResponseEntity<List<HsOfficeDebitorResource>> listDebitors(
final String currentUser,
final String assumedRoles,
final String name,
final Integer debitorNumber) {
context.define(currentUser, assumedRoles);
final var entities = debitorNumber != null
? debitorRepo.findDebitorByDebitorNumber(debitorNumber)
: debitorRepo.findDebitorByOptionalNameLike(name);
final var resources = Mapper.mapList(entities, HsOfficeDebitorResource.class,
DEBITOR_ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.ok(resources);
}
@Override
@Transactional
public ResponseEntity<HsOfficeDebitorResource> addDebitor(
final String currentUser,
final String assumedRoles,
final HsOfficeDebitorInsertResource body) {
context.define(currentUser, assumedRoles);
final var entityToSave = map(body, HsOfficeDebitorEntity.class, DEBITOR_RESOURCE_TO_ENTITY_POSTMAPPER);
entityToSave.setUuid(UUID.randomUUID());
final var saved = debitorRepo.save(entityToSave);
final var uri =
MvcUriComponentsBuilder.fromController(getClass())
.path("/api/hs/office/debitors/{id}")
.buildAndExpand(entityToSave.getUuid())
.toUri();
final var mapped = map(saved, HsOfficeDebitorResource.class,
DEBITOR_ENTITY_TO_RESOURCE_POSTMAPPER);
return ResponseEntity.created(uri).body(mapped);
}
@Override
@Transactional(readOnly = true)
public ResponseEntity<HsOfficeDebitorResource> getDebitorByUuid(
final String currentUser,
final String assumedRoles,
final UUID debitorUuid) {
context.define(currentUser, assumedRoles);
final var result = debitorRepo.findByUuid(debitorUuid);
if (result.isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(map(result.get(), HsOfficeDebitorResource.class, DEBITOR_ENTITY_TO_RESOURCE_POSTMAPPER));
}
@Override
@Transactional
public ResponseEntity<Void> deleteDebitorByUuid(
final String currentUser,
final String assumedRoles,
final UUID debitorUuid) {
context.define(currentUser, assumedRoles);
final var result = debitorRepo.deleteByUuid(debitorUuid);
if (result == 0) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.noContent().build();
}
@Override
@Transactional
public ResponseEntity<HsOfficeDebitorResource> patchDebitor(
final String currentUser,
final String assumedRoles,
final UUID debitorUuid,
final HsOfficeDebitorPatchResource body) {
context.define(currentUser, assumedRoles);
final var current = debitorRepo.findByUuid(debitorUuid).orElseThrow();
new HsOfficeDebitorEntityPatcher(em, current).apply(body);
final var saved = debitorRepo.save(current);
final var mapped = map(saved, HsOfficeDebitorResource.class);
return ResponseEntity.ok(mapped);
}
final BiConsumer<HsOfficeDebitorEntity, HsOfficeDebitorResource> DEBITOR_ENTITY_TO_RESOURCE_POSTMAPPER = (entity, resource) -> {
resource.setPartner(map(entity.getPartner(), HsOfficePartnerResource.class));
resource.setBillingContact(map(entity.getBillingContact(), HsOfficeContactResource.class));
};
final BiConsumer<HsOfficeDebitorInsertResource, HsOfficeDebitorEntity> DEBITOR_RESOURCE_TO_ENTITY_POSTMAPPER = (resource, entity) -> {
entity.setPartner(em.getReference(HsOfficePartnerEntity.class, resource.getPartnerUuid()));
entity.setBillingContact(em.getReference(HsOfficeContactEntity.class, resource.getBillingContactUuid()));
};
}

View File

@ -0,0 +1,57 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import lombok.*;
import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.Stringify;
import net.hostsharing.hsadminng.Stringifyable;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity;
import javax.persistence.*;
import java.util.UUID;
import static net.hostsharing.hsadminng.Stringify.stringify;
@Entity
@Table(name = "hs_office_debitor_rv")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DisplayName("Debitor")
public class HsOfficeDebitorEntity implements Stringifyable {
private static Stringify<HsOfficeDebitorEntity> stringify =
stringify(HsOfficeDebitorEntity.class, "debitor")
.withProp(HsOfficeDebitorEntity::getDebitorNumber)
.withProp(HsOfficeDebitorEntity::getPartner)
.withSeparator(": ")
.quotedValues(false);
private @Id UUID uuid;
@ManyToOne
@JoinColumn(name = "partneruuid")
private HsOfficePartnerEntity partner;
private @Column(name = "debitornumber") Integer debitorNumber;
@ManyToOne
@JoinColumn(name = "billingcontactuuid")
private HsOfficeContactEntity billingContact;
private @Column(name = "vatid") String vatId;
private @Column(name = "vatcountrycode") String vatCountryCode;
private @Column(name = "vatbusiness") boolean vatBusiness;
@Override
public String toString() {
return stringify.apply(this);
}
@Override
public String toShortString() {
return debitorNumber.toString();
}
}

View File

@ -0,0 +1,41 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import net.hostsharing.hsadminng.EntityPatcher;
import net.hostsharing.hsadminng.OptionalFromJson;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeDebitorPatchResource;
import javax.persistence.EntityManager;
class HsOfficeDebitorEntityPatcher implements EntityPatcher<HsOfficeDebitorPatchResource> {
private final EntityManager em;
private final HsOfficeDebitorEntity entity;
HsOfficeDebitorEntityPatcher(
final EntityManager em,
final HsOfficeDebitorEntity entity) {
this.em = em;
this.entity = entity;
}
@Override
public void apply(final HsOfficeDebitorPatchResource resource) {
OptionalFromJson.of(resource.getBillingContactUuid()).ifPresent(newValue -> {
verifyNotNull(newValue, "billingContact");
entity.setBillingContact(em.getReference(HsOfficeContactEntity.class, newValue));
});
OptionalFromJson.of(resource.getVatId()).ifPresent(entity::setVatId);
OptionalFromJson.of(resource.getVatCountryCode()).ifPresent(entity::setVatCountryCode);
OptionalFromJson.of(resource.getVatBusiness()).ifPresent(newValue -> {
verifyNotNull(newValue, "vatBusiness");
entity.setVatBusiness(newValue);
});
}
private void verifyNotNull(final Object newValue, final String propertyName) {
if (newValue == null) {
throw new IllegalArgumentException("property '" + propertyName + "' must not be null");
}
}
}

View File

@ -0,0 +1,39 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface HsOfficeDebitorRepository extends Repository<HsOfficeDebitorEntity, UUID> {
Optional<HsOfficeDebitorEntity> findByUuid(UUID id);
@Query("""
SELECT debitor FROM HsOfficeDebitorEntity debitor
WHERE debitor.debitorNumber = :debitorNumber
""")
List<HsOfficeDebitorEntity> findDebitorByDebitorNumber(int debitorNumber);
@Query("""
SELECT debitor FROM HsOfficeDebitorEntity debitor
JOIN HsOfficePartnerEntity partner ON partner.uuid = debitor.partner
JOIN HsOfficePersonEntity person ON person.uuid = partner.person
JOIN HsOfficeContactEntity contact ON contact.uuid = debitor.billingContact
WHERE :name is null
OR partner.birthName like concat(:name, '%')
OR person.tradeName like concat(:name, '%')
OR person.familyName like concat(:name, '%')
OR person.givenName like concat(:name, '%')
OR contact.label like concat(:name, '%')
""")
List<HsOfficeDebitorEntity> findDebitorByOptionalNameLike(String name);
HsOfficeDebitorEntity save(final HsOfficeDebitorEntity entity);
long count();
int deleteByUuid(UUID uuid);
}

View File

@ -65,6 +65,7 @@ public class HsOfficePartnerController implements HsOfficePartnersApi {
final var entityToSave = mapToHsOfficePartnerEntity(body);
entityToSave.setUuid(UUID.randomUUID());
// TODO.impl: use getReference
entityToSave.setContact(contactRepo.findByUuid(body.getContactUuid()).orElseThrow(
() -> new NoSuchElementException("cannot find contact uuid " + body.getContactUuid())
));
@ -141,6 +142,7 @@ public class HsOfficePartnerController implements HsOfficePartnersApi {
resource.setContact(map(entity.getContact(), HsOfficeContactResource.class));
};
// TODO.impl: user postmapper + getReference
private HsOfficePartnerEntity mapToHsOfficePartnerEntity(final HsOfficePartnerInsertResource resource) {
final var entity = new HsOfficePartnerEntity();
entity.setBirthday(resource.getBirthday());

View File

@ -1,6 +1,7 @@
package net.hostsharing.hsadminng.hs.office.partner;
import lombok.*;
import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.Stringify;
import net.hostsharing.hsadminng.Stringifyable;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
@ -19,6 +20,7 @@ import static net.hostsharing.hsadminng.Stringify.stringify;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@DisplayName("Partner")
public class HsOfficePartnerEntity implements Stringifyable {
private static Stringify<HsOfficePartnerEntity> stringify = stringify(HsOfficePartnerEntity.class, "partner")

View File

@ -3,6 +3,7 @@ package net.hostsharing.hsadminng.hs.office.person;
import com.vladmihalcea.hibernate.type.basic.PostgreSQLEnumType;
import lombok.*;
import lombok.experimental.FieldNameConstants;
import net.hostsharing.hsadminng.errors.DisplayName;
import net.hostsharing.hsadminng.Stringify;
import net.hostsharing.hsadminng.Stringifyable;
import org.apache.commons.lang3.StringUtils;
@ -26,6 +27,7 @@ import static net.hostsharing.hsadminng.Stringify.stringify;
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
@DisplayName("Person")
public class HsOfficePersonEntity implements Stringifyable {
private static Stringify<HsOfficePersonEntity> toString = stringify(HsOfficePersonEntity.class, "person")
@ -50,10 +52,6 @@ public class HsOfficePersonEntity implements Stringifyable {
@Column(name = "givenname")
private String givenName;
public String getDisplayName() {
return toShortString();
}
@Override
public String toString() {
return toString.apply(this);

View File

@ -20,3 +20,5 @@ map:
null: org.openapitools.jackson.nullable.JsonNullable
/api/hs/office/relationships/{relationshipUUID}:
null: org.openapitools.jackson.nullable.JsonNullable
/api/hs/office/debitors/{debitorUUID}:
null: org.openapitools.jackson.nullable.JsonNullable

View File

@ -0,0 +1,70 @@
components:
schemas:
HsOfficeDebitor:
type: object
properties:
uuid:
type: string
format: uuid
debitorNumber:
type: integer
format: int32
minimum: 10000
maximum: 99999
partner:
$ref: './hs-office-partner-schemas.yaml#/components/schemas/HsOfficePartner'
billingContact:
$ref: './hs-office-contact-schemas.yaml#/components/schemas/HsOfficeContact'
vatId:
type: string
vatCountryCode:
type: string
pattern: '^[A_Z][A-Z]$'
vatBusiness:
type: boolean
HsOfficeDebitorPatch:
type: object
properties:
billingContactUuid:
type: string
format: uuid
nullable: true
vatId:
type: string
nullable: true
vatCountryCode:
type: string
pattern: '^[A_Z][A-Z]$'
nullable: true
vatBusiness:
type: boolean
nullable: true
HsOfficeDebitorInsert:
type: object
properties:
partnerUuid:
type: string
format: uuid
billingContactUuid:
type: string
format: uuid
debitorNumber:
type: integer
format: int32
minimum: 10000
maximum: 99999
vatId:
type: string
vatCountryCode:
type: string
pattern: '^[A_Z][A-Z]$'
vatBusiness:
type: boolean
required:
- partnerUuid
- billingContactUuid

View File

@ -0,0 +1,83 @@
get:
tags:
- hs-office-debitors
description: 'Fetch a single debitor by its uuid, if visible for the current subject.'
operationId: getDebitorByUuid
parameters:
- $ref: './auth.yaml#/components/parameters/currentUser'
- $ref: './auth.yaml#/components/parameters/assumedRoles'
- name: debitorUUID
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the debitor to fetch.
responses:
"200":
description: OK
content:
'application/json':
schema:
$ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor'
"401":
$ref: './error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: './error-responses.yaml#/components/responses/Forbidden'
patch:
tags:
- hs-office-debitors
description: 'Updates a single debitor by its uuid, if permitted for the current subject.'
operationId: patchDebitor
parameters:
- $ref: './auth.yaml#/components/parameters/currentUser'
- $ref: './auth.yaml#/components/parameters/assumedRoles'
- name: debitorUUID
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
'application/json':
schema:
$ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitorPatch'
responses:
"200":
description: OK
content:
'application/json':
schema:
$ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor'
"401":
$ref: './error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: './error-responses.yaml#/components/responses/Forbidden'
delete:
tags:
- hs-office-debitors
description: 'Delete a single debitor by its uuid, if permitted for the current subject.'
operationId: deleteDebitorByUuid
parameters:
- $ref: './auth.yaml#/components/parameters/currentUser'
- $ref: './auth.yaml#/components/parameters/assumedRoles'
- name: debitorUUID
in: path
required: true
schema:
type: string
format: uuid
description: UUID of the debitor 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,62 @@
get:
summary: Returns a list of (optionally filtered) debitors.
description: Returns the list of (optionally filtered) debitors which are visible to the current user or any of it's assumed roles.
tags:
- hs-office-debitors
operationId: listDebitors
parameters:
- $ref: './auth.yaml#/components/parameters/currentUser'
- $ref: './auth.yaml#/components/parameters/assumedRoles'
- name: name
in: query
required: false
schema:
type: string
description: Prefix of name properties from person or contact to filter the results.
- name: debitorNumber
in: query
required: false
schema:
type: integer
description: Debitor number of the requested debitor.
responses:
"200":
description: OK
content:
'application/json':
schema:
type: array
items:
$ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor'
"401":
$ref: './error-responses.yaml#/components/responses/Unauthorized'
"403":
$ref: './error-responses.yaml#/components/responses/Forbidden'
post:
summary: Adds a new debitor.
tags:
- hs-office-debitors
operationId: addDebitor
parameters:
- $ref: './auth.yaml#/components/parameters/currentUser'
- $ref: './auth.yaml#/components/parameters/assumedRoles'
requestBody:
content:
'application/json':
schema:
$ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitorInsert'
required: true
responses:
"201":
description: Created
content:
'application/json':
schema:
$ref: './hs-office-debitor-schemas.yaml#/components/schemas/HsOfficeDebitor'
"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

@ -35,7 +35,6 @@ paths:
$ref: "./hs-office-persons-with-uuid.yaml"
# Relationships
/api/hs/office/relationships:
@ -44,3 +43,11 @@ paths:
/api/hs/office/relationships/{relationshipUUID}:
$ref: "./hs-office-relationships-with-uuid.yaml"
# Debitors
/api/hs/office/debitors:
$ref: "./hs-office-debitors.yaml"
/api/hs/office/debitors/{debitorUUID}:
$ref: "./hs-office-debitors-with-uuid.yaml"

View File

@ -63,6 +63,7 @@ do language plpgsql $$
call createHsOfficePersonTestData('NATURAL', null, 'Smith', 'Peter');
call createHsOfficePersonTestData('LEGAL', 'Second e.K.', 'Sandra', 'Miller');
call createHsOfficePersonTestData('SOLE_REPRESENTATION', 'Third OHG');
call createHsOfficePersonTestData('SOLE_REPRESENTATION', 'Fourth e.G.');
call createHsOfficePersonTestData('JOINT_REPRESENTATION', 'Erben Bessler', 'Mel', 'Bessler');
call createHsOfficePersonTestData('NATURAL', null, 'Bessler', 'Anita');
call createHsOfficePersonTestData('NATURAL', null, 'Winkler', 'Paul');

View File

@ -64,10 +64,9 @@ end; $$;
do language plpgsql $$
begin
call createHsOfficePartnerTestData('First GmbH', 'first contact');
call createHsOfficePartnerTestData('Second e.K.', 'second contact');
call createHsOfficePartnerTestData('Third OHG', 'third contact');
call createHsOfficePartnerTestData('Fourth e.G.', 'forth contact');
end;
$$;
--//

View File

@ -0,0 +1,18 @@
--liquibase formatted sql
-- ============================================================================
--changeset hs-office-debitor-MAIN-TABLE:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
create table hs_office_debitor
(
uuid uuid unique references RbacObject (uuid) initially deferred,
partnerUuid uuid not null references hs_office_partner(uuid),
debitorNumber numeric(5) not null,
billingContactUuid uuid not null references hs_office_contact(uuid),
vatId varchar(24), -- TODO.spec: here or in person?
vatCountryCode varchar(2),
vatBusiness boolean not null -- TODO.spec: more of such?
-- TODO.impl: SEPA-mandate + bank account
);
--//

View File

@ -0,0 +1,24 @@
### hs_office_debitor RBAC Roles
```mermaid
graph TD;
%% role:debitor.owner
role:debitor.owner --> perm:debitor.*;
role:global.admin --> role:debitor.owner;
%% role:debitor.admin
role:debitor.admin --> perm:debitor.edit;
role:debitor.owner --> role:debitor.admin;
%% role:debitor.tenant
role:debitor.tenant --> perm:debitor.view;
%% super-roles
role:debitor.admin --> role:debitor.tenant;
role:partner.admin --> role:debitor.tenant;
role:person.admin --> role:debitor.tenant;
role:contact.admin --> role:debitor.tenant;
%% sub-roles
role:debitor.tenant --> role:partner.tenant;
role:debitor.tenant --> role:person.tenant;
role:debitor.tenant --> role:contact.tenant;
```

View File

@ -0,0 +1,192 @@
--liquibase formatted sql
-- ============================================================================
--changeset hs-office-debitor-rbac-OBJECT:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRelatedRbacObject('hs_office_debitor');
--//
-- ============================================================================
--changeset hs-office-debitor-rbac-ROLE-DESCRIPTORS:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRbacRoleDescriptors('hsOfficeDebitor', 'hs_office_debitor');
--//
-- ============================================================================
--changeset hs-office-debitor-rbac-ROLES-CREATION:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates and updates the roles and their assignments for debitor entities.
*/
create or replace function hsOfficeDebitorRbacRolesTrigger()
returns trigger
language plpgsql
strict as $$
declare
hsOfficeDebitorTenant RbacRoleDescriptor;
ownerRole uuid;
adminRole uuid;
oldPartner hs_office_partner;
newPartner hs_office_partner;
newPerson hs_office_person;
oldContact hs_office_contact;
newContact hs_office_contact;
begin
hsOfficeDebitorTenant := hsOfficeDebitorTenant(NEW);
select * from hs_office_partner as p where p.uuid = NEW.partnerUuid into newPartner;
select * from hs_office_person as p where p.uuid = newPartner.personUuid into newPerson;
select * from hs_office_contact as c where c.uuid = NEW.billingContactUuid into newContact;
if TG_OP = 'INSERT' then
-- the owner role with full access for the global admins
ownerRole = createRole(
hsOfficeDebitorOwner(NEW),
grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['*']),
beneathRole(globalAdmin())
);
-- the admin role with full access for owner
adminRole = createRole(
hsOfficeDebitorAdmin(NEW),
grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['edit']),
beneathRole(ownerRole)
);
-- the tenant role for those related users who can view the data
perform createRole(
hsOfficeDebitorTenant,
grantingPermissions(forObjectUuid => NEW.uuid, permitOps => array ['view']),
beneathRoles(array[
hsOfficeDebitorAdmin(NEW),
hsOfficePartnerAdmin(newPartner),
hsOfficePersonAdmin(newPerson),
hsOfficeContactAdmin(newContact)]),
withSubRoles(array[
hsOfficePartnerTenant(newPartner),
hsOfficePersonTenant(newPerson),
hsOfficeContactTenant(newContact)])
);
elsif TG_OP = 'UPDATE' then
if OLD.partnerUuid <> NEW.partnerUuid then
select * from hs_office_partner as p where p.uuid = OLD.partnerUuid into oldPartner;
call revokeRoleFromRole( hsOfficeDebitorTenant, hsOfficePartnerAdmin(oldPartner) );
call grantRoleToRole( hsOfficeDebitorTenant, hsOfficePartnerAdmin(newPartner) );
call revokeRoleFromRole( hsOfficePartnerTenant(oldPartner), hsOfficeDebitorTenant );
call grantRoleToRole( hsOfficePartnerTenant(newPartner), hsOfficeDebitorTenant );
end if;
if OLD.billingContactUuid <> NEW.billingContactUuid then
select * from hs_office_contact as c where c.uuid = OLD.billingContactUuid into oldContact;
call revokeRoleFromRole( hsOfficeDebitorTenant, hsOfficeContactAdmin(oldContact) );
call grantRoleToRole( hsOfficeDebitorTenant, hsOfficeContactAdmin(newContact) );
call revokeRoleFromRole( hsOfficeContactTenant(oldContact), hsOfficeDebitorTenant );
call grantRoleToRole( hsOfficeContactTenant(newContact), hsOfficeDebitorTenant );
end if;
else
raise exception 'invalid usage of TRIGGER';
end if;
return NEW;
end; $$;
/*
An AFTER INSERT TRIGGER which creates the role structure for a new debitor.
*/
create trigger createRbacRolesForHsOfficeDebitor_Trigger
after insert
on hs_office_debitor
for each row
execute procedure hsOfficeDebitorRbacRolesTrigger();
/*
An AFTER UPDATE TRIGGER which updates the role structure of a debitor.
*/
create trigger updateRbacRolesForHsOfficeDebitor_Trigger
after update
on hs_office_debitor
for each row
execute procedure hsOfficeDebitorRbacRolesTrigger();
--//
-- ============================================================================
--changeset hs-office-debitor-rbac-IDENTITY-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRbacIdentityView('hs_office_debitor', $idName$
'#' || debitorNumber || ':' ||
(select idName from hs_office_partner_iv p where p.uuid = target.partnerUuid)
$idName$);
--//
-- ============================================================================
--changeset hs-office-debitor-rbac-RESTRICTED-VIEW:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
call generateRbacRestrictedView('hs_office_debitor',
'target.debitorNumber',
$updates$
billingContactUuid = new.billingContactUuid,
vatId = new.vatId,
vatCountryCode = new.vatCountryCode,
vatBusiness = new.vatBusiness
$updates$);
--//
-- ============================================================================
--changeset hs-office-debitor-rbac-NEW-DEBITOR:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates a global permission for new-debitor and assigns it to the hostsharing admins role.
*/
do language plpgsql $$
declare
addDebitorPermissions uuid[];
globalObjectUuid uuid;
globalAdminRoleUuid uuid ;
begin
call defineContext('granting global new-debitor permission to global admin role', null, null, null);
globalAdminRoleUuid := findRoleId(globalAdmin());
globalObjectUuid := (select uuid from global);
addDebitorPermissions := createPermissions(globalObjectUuid, array ['new-debitor']);
call grantPermissionsToRole(globalAdminRoleUuid, addDebitorPermissions);
end;
$$;
/**
Used by the trigger to prevent the add-debitor to current user respectively assumed roles.
*/
create or replace function addHsOfficeDebitorNotAllowedForCurrentSubjects()
returns trigger
language PLPGSQL
as $$
begin
raise exception '[403] new-debitor not permitted for %',
array_to_string(currentSubjects(), ';', 'null');
end; $$;
/**
Checks if the user or assumed roles are allowed to create a new debitor.
*/
create trigger hs_office_debitor_insert_trigger
before insert
on hs_office_debitor
for each row
-- TODO.spec: who is allowed to create new debitors
when ( not hasAssumedRole() )
execute procedure addHsOfficeDebitorNotAllowedForCurrentSubjects();
--//

View File

@ -0,0 +1,52 @@
--liquibase formatted sql
-- ============================================================================
--changeset hs-office-debitor-TEST-DATA-GENERATOR:1 endDelimiter:--//
-- ----------------------------------------------------------------------------
/*
Creates a single debitor test record.
*/
create or replace procedure createHsOfficeDebitorTestData( partnerTradeName varchar, billingContactLabel varchar )
language plpgsql as $$
declare
currentTask varchar;
idName varchar;
relatedPartner hs_office_partner;
relatedContact hs_office_contact;
newDebitorNumber numeric(6);
begin
idName := cleanIdentifier( partnerTradeName|| '-' || billingContactLabel);
currentTask := 'creating RBAC test debitor ' || idName;
call defineContext(currentTask, null, 'superuser-alex@hostsharing.net', 'global#global.admin');
execute format('set local hsadminng.currentTask to %L', currentTask);
select partner.* from hs_office_partner partner
join hs_office_person person on person.uuid = partner.personUuid
where person.tradeName = partnerTradeName into relatedPartner;
select c.* from hs_office_contact c where c.label = billingContactLabel into relatedContact;
select coalesce(max(debitorNumber)+1, 10001) from hs_office_debitor into newDebitorNumber;
raise notice 'creating test debitor: % (#%)', idName, newDebitorNumber;
raise notice '- using partner (%): %', relatedPartner.uuid, relatedPartner;
raise notice '- using billingContact (%): %', relatedContact.uuid, relatedContact;
insert
into hs_office_debitor (uuid, partneruuid, debitornumber, billingcontactuuid, vatbusiness)
values (uuid_generate_v4(), relatedPartner.uuid, newDebitorNumber, relatedContact.uuid, true);
end; $$;
--//
-- ============================================================================
--changeset hs-office-debitor-TEST-DATA-GENERATION:1 context=dev,tc endDelimiter:--//
-- ----------------------------------------------------------------------------
do language plpgsql $$
begin
call createHsOfficeDebitorTestData('First GmbH', 'first contact');
call createHsOfficeDebitorTestData('Second e.K.', 'second contact');
call createHsOfficeDebitorTestData('Third OHG', 'third contact');
end;
$$;
--//

View File

@ -71,3 +71,9 @@ databaseChangeLog:
file: db/changelog/233-hs-office-relationship-rbac.sql
- include:
file: db/changelog/238-hs-office-relationship-test-data.sql
- include:
file: db/changelog/270-hs-office-debitor.sql
- include:
file: db/changelog/273-hs-office-debitor-rbac.sql
- include:
file: db/changelog/278-hs-office-debitor-test-data.sql

View File

@ -132,7 +132,7 @@ public abstract class PatchUnitTestBase<R, E> {
protected abstract EntityPatcher<R> createPatcher(final E entity);
@SuppressWarnings("rawtypes")
@SuppressWarnings("types")
protected abstract Stream<Property> propertyTestDescriptors();
private Stream<Arguments> propertyTestCases() {

View File

@ -17,10 +17,16 @@ public class ArchitectureTest {
public static final String NET_HOSTSHARING_HSADMINNG = "net.hostsharing.hsadminng";
@ArchTest
@SuppressWarnings("unused")
public static final ArchRule doNotUseJUnit4 = noClasses()
.should().accessClassesThat()
.resideInAPackage("org.junit");
@ArchTest
@SuppressWarnings("unused")
public static final ArchRule dontUseImplSuffix = noClasses()
.should().haveSimpleNameEndingWith("Impl");
@ArchTest
@SuppressWarnings("unused")
public static final ArchRule contextPackageRule = classes()
@ -68,7 +74,7 @@ public class ArchitectureTest {
public static final ArchRule HsOfficePartnerPackageRule = classes()
.that().resideInAPackage("..hs.office.partner..")
.should().onlyBeAccessed().byClassesThat()
.resideInAnyPackage("..hs.office.partner..");
.resideInAnyPackage("..hs.office.partner..", "..hs.office.debitor..");
@ArchTest
@SuppressWarnings("unused")

View File

@ -4,9 +4,14 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.orm.jpa.JpaObjectRetrievalFailureException;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.web.context.request.WebRequest;
import javax.persistence.EntityNotFoundException;
import java.util.NoSuchElementException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@ -44,6 +49,75 @@ class RestResponseEntityExceptionHandlerUnitTest {
assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [401] First Line");
}
@Test
void handleJpaObjectRetrievalFailureExceptionWithDisplayName() {
// given
final var givenException = new JpaObjectRetrievalFailureException(
new EntityNotFoundException(
"Unable to find net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity with id 12345-123454")
);
final var givenWebRequest = mock(WebRequest.class);
// when
final var errorResponse = exceptionHandler.handleJpaObjectRetrievalFailureException(givenException, givenWebRequest);
// then
assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400);
assertThat(errorResponse.getBody().getMessage()).isEqualTo("Unable to find Partner with uuid 12345-123454");
}
@Test
void handleJpaObjectRetrievalFailureExceptionIfEntityClassCannotBeDetermined() {
// given
final var givenException = new JpaObjectRetrievalFailureException(
new EntityNotFoundException(
"Unable to find net.hostsharing.hsadminng.WhateverEntity with id 12345-123454")
);
final var givenWebRequest = mock(WebRequest.class);
// when
final var errorResponse = exceptionHandler.handleJpaObjectRetrievalFailureException(givenException, givenWebRequest);
// then
assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400);
assertThat(errorResponse.getBody().getMessage()).isEqualTo(
"Unable to find net.hostsharing.hsadminng.WhateverEntity with id 12345-123454");
}
@Test
void handleJpaObjectRetrievalFailureExceptionIfPatternDoesNotMatch() {
// given
final var givenException = new JpaObjectRetrievalFailureException(
new EntityNotFoundException("whatever error message")
);
final var givenWebRequest = mock(WebRequest.class);
// when
final var errorResponse = exceptionHandler.handleJpaObjectRetrievalFailureException(givenException, givenWebRequest);
// then
assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400);
assertThat(errorResponse.getBody().getMessage()).isEqualTo("whatever error message");
}
@Test
void handleJpaObjectRetrievalFailureExceptionWithEntityName() {
// given
final var givenException = new JpaObjectRetrievalFailureException(
new EntityNotFoundException("Unable to find "
+ NoDisplayNameEntity.class.getTypeName()
+ " with id 12345-123454")
);
final var givenWebRequest = mock(WebRequest.class);
// when
final var errorResponse = exceptionHandler.handleJpaObjectRetrievalFailureException(givenException, givenWebRequest);
// then
assertThat(errorResponse.getStatusCodeValue()).isEqualTo(400);
assertThat(errorResponse.getBody().getMessage()).isEqualTo("Unable to find NoDisplayNameEntity with uuid 12345-123454");
}
@Test
void jpaExceptionWithUnknownErrorCode() {
// given
@ -59,6 +133,20 @@ class RestResponseEntityExceptionHandlerUnitTest {
assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [999] First Line");
}
@Test
void handleNoSuchElementException() {
// given
final var givenException = new NoSuchElementException("some error message");
final var givenWebRequest = mock(WebRequest.class);
// when
final var errorResponse = exceptionHandler.handleNoSuchElementException(givenException, givenWebRequest);
// then
assertThat(errorResponse.getStatusCodeValue()).isEqualTo(404);
assertThat(errorResponse.getBody().getMessage()).isEqualTo("some error message");
}
@Test
void handleOtherExceptionsWithoutErrorCode() {
// given
@ -87,4 +175,8 @@ class RestResponseEntityExceptionHandlerUnitTest {
assertThat(errorResponse.getBody().getMessage()).isEqualTo("ERROR: [418] First Line");
}
public static class NoDisplayNameEntity {
}
}

View File

@ -0,0 +1,505 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import net.hostsharing.hsadminng.Accepts;
import net.hostsharing.hsadminng.HsadminNgApplication;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository;
import net.hostsharing.test.JpaAttempt;
import org.json.JSONException;
import org.junit.jupiter.api.AfterEach;
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 java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import static net.hostsharing.test.IsValidUuidMatcher.isUuidValid;
import static net.hostsharing.test.JsonMatcher.lenientlyEquals;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assumptions.assumeThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
classes = { HsadminNgApplication.class, JpaAttempt.class }
)
@Transactional
class HsOfficeDebitorControllerAcceptanceTest {
private static int nextDebitorNumber = 20001;
@LocalServerPort
private Integer port;
@Autowired
Context context;
@Autowired
Context contextMock;
@Autowired
HsOfficeDebitorRepository debitorRepo;
@Autowired
HsOfficePartnerRepository partnerRepo;
@Autowired
HsOfficeContactRepository contactRepo;
@Autowired
JpaAttempt jpaAttempt;
Set<UUID> tempDebitorUuids = new HashSet<>();
@Nested
@Accepts({ "Debitor:F(Find)" })
class ListDebitors {
@Test
void globalAdmin_withoutAssumedRoles_canViewAllDebitors_ifNoCriteriaGiven() throws JSONException {
RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.port(port)
.when()
.get("http://localhost/api/hs/office/debitors")
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("", lenientlyEquals("""
[
{
"debitorNumber": 10001,
"partner": { "person": { "personType": "LEGAL" } },
"billingContact": { "label": "first contact" },
"vatId": null,
"vatCountryCode": null,
"vatBusiness": true
},
{
"debitorNumber": 10002,
"partner": { "person": { "tradeName": "Second e.K." } },
"billingContact": { "label": "second contact" },
"vatId": null,
"vatCountryCode": null,
"vatBusiness": true
},
{
"debitorNumber": 10003,
"partner": { "person": { "tradeName": "Third OHG" } },
"billingContact": { "label": "third contact" },
"vatId": null,
"vatCountryCode": null,
"vatBusiness": true
}
]
"""));
// @formatter:on
}
@Test
void globalAdmin_withoutAssumedRoles_canFindDebitorDebitorByDebitorNumber() throws JSONException {
RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.port(port)
.when()
.get("http://localhost/api/hs/office/debitors?debitorNumber=10002")
.then().log().all().assertThat()
.statusCode(200)
.contentType("application/json")
.body("", lenientlyEquals("""
[
{
"debitorNumber": 10002,
"partner": { "person": { "tradeName": "Second e.K." } },
"billingContact": { "label": "second contact" },
"vatId": null,
"vatCountryCode": null,
"vatBusiness": true
}
]
"""));
// @formatter:on
}
}
@Nested
@Accepts({ "Debitor:C(Create)" })
class AddDebitor {
@Test
void globalAdmin_withoutAssumedRole_canAddDebitor() {
context.define("superuser-alex@hostsharing.net");
final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Third").get(0);
final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0);
final var location = RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"partnerUuid": "%s",
"billingContactUuid": "%s",
"debitorNumber": "%s",
"vatId": "VAT123456",
"vatCountryCode": "DE",
"vatBusiness": true
}
""".formatted( givenPartner.getUuid(), givenContact.getUuid(), nextDebitorNumber++))
.port(port)
.when()
.post("http://localhost/api/hs/office/debitors")
.then().log().all().assertThat()
.statusCode(201)
.contentType(ContentType.JSON)
.body("uuid", isUuidValid())
.body("vatId", is("VAT123456"))
.body("billingContact.label", is(givenContact.getLabel()))
.body("partner.person.tradeName", is(givenPartner.getPerson().getTradeName()))
.header("Location", startsWith("http://localhost"))
.extract().header("Location"); // @formatter:on
// finally, the new debitor can be accessed under the generated UUID
final var newUserUuid = toCleanup(UUID.fromString(
location.substring(location.lastIndexOf('/') + 1)));
assertThat(newUserUuid).isNotNull();
}
@Test
void globalAdmin_canNotAddDebitor_ifContactDoesNotExist() {
context.define("superuser-alex@hostsharing.net");
final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Third").get(0);
final var givenContactUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6");
final var location = RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"partnerUuid": "%s",
"billingContactUuid": "%s",
"debitorNumber": "%s",
"vatId": "VAT123456",
"vatCountryCode": "DE",
"vatBusiness": true
}
""".formatted( givenPartner.getUuid(), givenContactUuid, nextDebitorNumber++))
.port(port)
.when()
.post("http://localhost/api/hs/office/debitors")
.then().log().all().assertThat()
.statusCode(400)
.body("message", is("Unable to find Contact with uuid 3fa85f64-5717-4562-b3fc-2c963f66afa6"));
// @formatter:on
}
@Test
void globalAdmin_canNotAddDebitor_ifPartnerDoesNotExist() {
context.define("superuser-alex@hostsharing.net");
final var givenPartnerUuid = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6");
final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0);
final var location = RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"partnerUuid": "%s",
"billingContactUuid": "%s",
"debitorNumber": "%s",
"vatId": "VAT123456",
"vatCountryCode": "DE",
"vatBusiness": true
}
""".formatted( givenPartnerUuid, givenContact.getUuid(), nextDebitorNumber++))
.port(port)
.when()
.post("http://localhost/api/hs/office/debitors")
.then().log().all().assertThat()
.statusCode(400)
.body("message", is("Unable to find Partner with uuid 3fa85f64-5717-4562-b3fc-2c963f66afa6"));
// @formatter:on
}
}
@Nested
@Accepts({ "Debitor:R(Read)" })
class GetDebitor {
@Test
void globalAdmin_withoutAssumedRole_canGetArbitraryDebitor() {
context.define("superuser-alex@hostsharing.net");
final var givenDebitorUuid = debitorRepo.findDebitorByOptionalNameLike("First").get(0).getUuid();
RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.port(port)
.when()
.get("http://localhost/api/hs/office/debitors/" + givenDebitorUuid)
.then().log().body().assertThat()
.statusCode(200)
.contentType("application/json")
.body("", lenientlyEquals("""
{
"partner": { person: { "tradeName": "First GmbH" } },
"billingContact": { "label": "first contact" }
}
""")); // @formatter:on
}
@Test
@Accepts({ "Debitor:X(Access Control)" })
void normalUser_canNotGetUnrelatedDebitor() {
context.define("superuser-alex@hostsharing.net");
final var givenDebitorUuid = debitorRepo.findDebitorByOptionalNameLike("First").get(0).getUuid();
RestAssured // @formatter:off
.given()
.header("current-user", "selfregistered-user-drew@hostsharing.org")
.port(port)
.when()
.get("http://localhost/api/hs/office/debitors/" + givenDebitorUuid)
.then().log().body().assertThat()
.statusCode(404); // @formatter:on
}
@Test
@Accepts({ "Debitor:X(Access Control)" })
void contactAdminUser_canGetRelatedDebitor() {
context.define("superuser-alex@hostsharing.net");
final var givenDebitorUuid = debitorRepo.findDebitorByOptionalNameLike("first contact").get(0).getUuid();
RestAssured // @formatter:off
.given()
.header("current-user", "contact-admin@firstcontact.example.com")
.port(port)
.when()
.get("http://localhost/api/hs/office/debitors/" + givenDebitorUuid)
.then().log().body().assertThat()
.statusCode(200)
.contentType("application/json")
.body("", lenientlyEquals("""
{
"partner": { person: { "tradeName": "First GmbH" } },
"billingContact": { "label": "first contact" }
}
""")); // @formatter:on
}
}
@Nested
@Accepts({ "Debitor:U(Update)" })
class PatchDebitor {
@Test
void globalAdmin_withoutAssumedRole_canPatchAllPropertiesOfArbitraryDebitor() {
context.define("superuser-alex@hostsharing.net");
final var givenDebitor = givenSomeTemporaryDebitor();
final var givenContact = contactRepo.findContactByOptionalLabelLike("forth").get(0);
final var location = RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"contactUuid": "%s",
"vatId": "VAT222222",
"vatCountryCode": "AA",
"vatBusiness": true
}
""".formatted(givenContact.getUuid()))
.port(port)
.when()
.patch("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid())
.then().assertThat()
.statusCode(200)
.contentType(ContentType.JSON)
.body("uuid", isUuidValid())
.body("vatId", is("VAT222222"))
.body("vatCountryCode", is("AA"))
.body("vatBusiness", is(true))
.body("billingContact.label", is(givenContact.getLabel()))
.body("partner.person.tradeName", is(givenDebitor.getPartner().getPerson().getTradeName()));
// @formatter:on
// finally, the debitor is actually updated
context.define("superuser-alex@hostsharing.net");
assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isPresent().get()
.matches(partner -> {
assertThat(partner.getPartner().getPerson().getTradeName()).isEqualTo(givenDebitor.getPartner()
.getPerson()
.getTradeName());
assertThat(partner.getBillingContact().getLabel()).isEqualTo("forth contact");
assertThat(partner.getVatId()).isEqualTo("VAT222222");
assertThat(partner.getVatCountryCode()).isEqualTo("AA");
assertThat(partner.isVatBusiness()).isEqualTo(true);
return true;
});
}
@Test
void globalAdmin_withoutAssumedRole_canPatchPartialPropertiesOfArbitraryDebitor() {
context.define("superuser-alex@hostsharing.net");
final var givenDebitor = givenSomeTemporaryDebitor();
final var newBillingContact = contactRepo.findContactByOptionalLabelLike("sixth").get(0);
final var location = RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.contentType(ContentType.JSON)
.body("""
{
"billingContactUuid": "%s",
"vatId": "VAT999999"
}
""".formatted(newBillingContact.getUuid()))
.port(port)
.when()
.patch("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid())
.then().assertThat()
.statusCode(200)
.contentType(ContentType.JSON)
.body("uuid", isUuidValid())
.body("billingContact.label", is("sixth contact"))
.body("vatId", is("VAT999999"))
.body("vatCountryCode", is(givenDebitor.getVatCountryCode()))
.body("vatBusiness", is(givenDebitor.isVatBusiness()));
// @formatter:on
// finally, the debitor is actually updated
assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isPresent().get()
.matches(partner -> {
assertThat(partner.getPartner().getPerson().getTradeName()).isEqualTo(givenDebitor.getPartner()
.getPerson()
.getTradeName());
assertThat(partner.getBillingContact().getLabel()).isEqualTo("sixth contact");
assertThat(partner.getVatId()).isEqualTo("VAT999999");
assertThat(partner.getVatCountryCode()).isEqualTo(givenDebitor.getVatCountryCode());
assertThat(partner.isVatBusiness()).isEqualTo(givenDebitor.isVatBusiness());
return true;
});
}
}
@Nested
@Accepts({ "Debitor:D(Delete)" })
class DeleteDebitor {
@Test
void globalAdmin_withoutAssumedRole_canDeleteArbitraryDebitor() {
context.define("superuser-alex@hostsharing.net");
final var givenDebitor = givenSomeTemporaryDebitor();
RestAssured // @formatter:off
.given()
.header("current-user", "superuser-alex@hostsharing.net")
.port(port)
.when()
.delete("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid())
.then().log().body().assertThat()
.statusCode(204); // @formatter:on
// then the given debitor is gone
assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isEmpty();
}
@Test
@Accepts({ "Debitor:X(Access Control)" })
void contactAdminUser_canNotDeleteRelatedDebitor() {
context.define("superuser-alex@hostsharing.net");
final var givenDebitor = givenSomeTemporaryDebitor();
assumeThat(givenDebitor.getBillingContact().getLabel()).isEqualTo("forth contact");
RestAssured // @formatter:off
.given()
.header("current-user", "contact-admin@forthcontact.example.com")
.port(port)
.when()
.delete("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid())
.then().log().body().assertThat()
.statusCode(403); // @formatter:on
// then the given debitor is still there
assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isNotEmpty();
}
@Test
@Accepts({ "Debitor:X(Access Control)" })
void normalUser_canNotDeleteUnrelatedDebitor() {
context.define("superuser-alex@hostsharing.net");
final var givenDebitor = givenSomeTemporaryDebitor();
assumeThat(givenDebitor.getBillingContact().getLabel()).isEqualTo("forth contact");
RestAssured // @formatter:off
.given()
.header("current-user", "selfregistered-user-drew@hostsharing.org")
.port(port)
.when()
.delete("http://localhost/api/hs/office/debitors/" + givenDebitor.getUuid())
.then().log().body().assertThat()
.statusCode(404); // @formatter:on
// then the given debitor is still there
assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isNotEmpty();
}
}
private HsOfficeDebitorEntity givenSomeTemporaryDebitor() {
return jpaAttempt.transacted(() -> {
context.define("superuser-alex@hostsharing.net");
final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Fourth").get(0);
final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0);
final var newDebitor = HsOfficeDebitorEntity.builder()
.uuid(UUID.randomUUID())
.debitorNumber(nextDebitorNumber++)
.partner(givenPartner)
.billingContact(givenContact)
.build();
toCleanup(newDebitor.getUuid());
return debitorRepo.save(newDebitor);
}).assertSuccessful().returnedValue();
}
private UUID toCleanup(final UUID tempDebitorUuid) {
tempDebitorUuids.add(tempDebitorUuid);
return tempDebitorUuid;
}
@AfterEach
void cleanup() {
tempDebitorUuids.forEach(uuid -> {
jpaAttempt.transacted(() -> {
context.define("superuser-alex@hostsharing.net", null);
System.out.println("DELETING temporary debitor: " + uuid);
final var count = debitorRepo.deleteByUuid(uuid);
System.out.println("DELETED temporary debitor: " + uuid + (count > 0 ? " successful" : " failed"));
});
});
}
}

View File

@ -0,0 +1,112 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import net.hostsharing.hsadminng.PatchUnitTestBase;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.generated.api.v1.model.HsOfficeDebitorPatchResource;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity;
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 javax.persistence.EntityManager;
import java.util.UUID;
import java.util.stream.Stream;
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 HsOfficeDebitorEntityPatcherUnitTest extends PatchUnitTestBase<
HsOfficeDebitorPatchResource,
HsOfficeDebitorEntity
> {
private static final UUID INITIAL_DEBITOR_UUID = UUID.randomUUID();
private static final UUID INITIAL_PARTNER_UUID = UUID.randomUUID();
private static final UUID INITIAL_CONTACT_UUID = UUID.randomUUID();
private static final UUID PATCHED_CONTACT_UUID = UUID.randomUUID();
private static final String PATCHED_VAT_COUNTRY_CODE = "ZZ";
private static final boolean PATCHED_VAT_BUSINESS = false;
private final HsOfficePartnerEntity givenInitialPartner = HsOfficePartnerEntity.builder()
.uuid(INITIAL_PARTNER_UUID)
.build();
private final HsOfficeContactEntity givenInitialContact = HsOfficeContactEntity.builder()
.uuid(INITIAL_CONTACT_UUID)
.build();
@Mock
private EntityManager em;
@BeforeEach
void initMocks() {
lenient().when(em.getReference(eq(HsOfficeContactEntity.class), any())).thenAnswer(invocation ->
HsOfficeContactEntity.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 HsOfficeDebitorEntity newInitialEntity() {
final var entity = new HsOfficeDebitorEntity();
entity.setUuid(INITIAL_DEBITOR_UUID);
entity.setPartner(givenInitialPartner);
entity.setBillingContact(givenInitialContact);
entity.setVatId("initial VAT-ID");
entity.setVatCountryCode("AA");
entity.setVatBusiness(true);
return entity;
}
@Override
protected HsOfficeDebitorPatchResource newPatchResource() {
return new HsOfficeDebitorPatchResource();
}
@Override
protected HsOfficeDebitorEntityPatcher createPatcher(final HsOfficeDebitorEntity debitor) {
return new HsOfficeDebitorEntityPatcher(em, debitor);
}
@Override
protected Stream<Property> propertyTestDescriptors() {
return Stream.of(
new JsonNullableProperty<>(
"billingContact",
HsOfficeDebitorPatchResource::setBillingContactUuid,
PATCHED_CONTACT_UUID,
HsOfficeDebitorEntity::setBillingContact,
newBillingContact(PATCHED_CONTACT_UUID))
.notNullable(),
new JsonNullableProperty<>(
"vatId",
HsOfficeDebitorPatchResource::setVatId,
"patched VAT-ID",
HsOfficeDebitorEntity::setVatId),
new JsonNullableProperty<>(
"vatCountryCode",
HsOfficeDebitorPatchResource::setVatCountryCode,
PATCHED_VAT_COUNTRY_CODE,
HsOfficeDebitorEntity::setVatCountryCode),
new JsonNullableProperty<>(
"vatBusiness",
HsOfficeDebitorPatchResource::setVatBusiness,
PATCHED_VAT_BUSINESS,
HsOfficeDebitorEntity::setVatBusiness)
.notNullable()
);
}
private HsOfficeContactEntity newBillingContact(final UUID uuid) {
final var newContact = new HsOfficeContactEntity();
newContact.setUuid(uuid);
return newContact;
}
}

View File

@ -0,0 +1,40 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity;
import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class HsOfficeDebitorEntityTest {
@Test
void toStringContainsPartnerAndContact() {
final var given = HsOfficeDebitorEntity.builder()
.debitorNumber(123456)
.partner(HsOfficePartnerEntity.builder()
.person(HsOfficePersonEntity.builder()
.tradeName("some trade name")
.build())
.birthName("some birth name")
.build())
.billingContact(HsOfficeContactEntity.builder().label("some label").build())
.build();
final var result = given.toString();
assertThat(result).isEqualTo("debitor(123456: some trade name)");
}
@Test
void toShortStringContainsPartnerAndContact() {
final var given = HsOfficeDebitorEntity.builder()
.debitorNumber(123456)
.build();
final var result = given.toShortString();
assertThat(result).isEqualTo("123456");
}
}

View File

@ -0,0 +1,464 @@
package net.hostsharing.hsadminng.hs.office.debitor;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.context.ContextBasedTest;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity;
import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRepository;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerEntity;
import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRepository;
import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository;
import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository;
import net.hostsharing.test.Array;
import net.hostsharing.test.JpaAttempt;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
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.ComponentScan;
import org.springframework.orm.jpa.JpaSystemException;
import org.springframework.test.annotation.DirtiesContext;
import javax.persistence.EntityManager;
import javax.servlet.http.HttpServletRequest;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantEntity.grantDisplaysOf;
import static net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleEntity.roleNamesOf;
import static net.hostsharing.test.JpaAttempt.attempt;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@ComponentScan(basePackageClasses = { HsOfficeDebitorRepository.class, Context.class, JpaAttempt.class })
@DirtiesContext
class HsOfficeDebitorRepositoryIntegrationTest extends ContextBasedTest {
@Autowired
HsOfficeDebitorRepository debitorRepo;
@Autowired
HsOfficePartnerRepository partnerRepo;
@Autowired
HsOfficeContactRepository contactRepo;
@Autowired
RawRbacRoleRepository rawRoleRepo;
@Autowired
RawRbacGrantRepository rawGrantRepo;
@Autowired
EntityManager em;
@Autowired
JpaAttempt jpaAttempt;
@MockBean
HttpServletRequest request;
Set<HsOfficeDebitorEntity> tempDebitors = new HashSet<>();
@Nested
class CreateDebitor {
@Test
public void testHostsharingAdmin_withoutAssumedRole_canCreateNewDebitor() {
// given
context("superuser-alex@hostsharing.net");
final var count = debitorRepo.count();
final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First GmbH").get(0);
final var givenContact = contactRepo.findContactByOptionalLabelLike("first contact").get(0);
// when
final var result = attempt(em, () -> {
final var newDebitor = toCleanup(HsOfficeDebitorEntity.builder()
.uuid(UUID.randomUUID())
.debitorNumber(20001)
.partner(rawReference(givenPartner))
.billingContact(rawReference(givenContact))
.build());
return debitorRepo.save(newDebitor);
});
// then
result.assertSuccessful();
assertThat(result.returnedValue()).isNotNull().extracting(HsOfficeDebitorEntity::getUuid).isNotNull();
assertThatDebitorIsPersisted(result.returnedValue());
assertThat(debitorRepo.count()).isEqualTo(count + 1);
}
@Test
public void createsAndGrantsRoles() {
// given
context("superuser-alex@hostsharing.net");
final var initialRoleNames = roleNamesOf(rawRoleRepo.findAll());
final var initialGrantNames = grantDisplaysOf(rawGrantRepo.findAll());
// when
attempt(em, () -> {
final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Fourth").get(0);
final var givenContact = contactRepo.findContactByOptionalLabelLike("forth contact").get(0);
final var newDebitor = toCleanup(HsOfficeDebitorEntity.builder()
.uuid(UUID.randomUUID())
.debitorNumber(20002)
.partner(rawReference(givenPartner))
.billingContact(rawReference(givenContact))
.build());
return debitorRepo.save(newDebitor);
}).assertSuccessful();
// then
assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(Array.from(
initialRoleNames,
"hs_office_debitor#20002Fourthe.G.-forthcontact.admin",
"hs_office_debitor#20002Fourthe.G.-forthcontact.owner",
"hs_office_debitor#20002Fourthe.G.-forthcontact.tenant"));
assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(Array.fromSkippingNull(
initialGrantNames,
"{ grant perm * on hs_office_debitor#20002Fourthe.G.-forthcontact to role hs_office_debitor#20002Fourthe.G.-forthcontact.owner by system and assume }",
"{ grant role hs_office_debitor#20002Fourthe.G.-forthcontact.owner to role global#global.admin by system and assume }",
"{ grant perm edit on hs_office_debitor#20002Fourthe.G.-forthcontact to role hs_office_debitor#20002Fourthe.G.-forthcontact.admin by system and assume }",
"{ grant role hs_office_debitor#20002Fourthe.G.-forthcontact.admin to role hs_office_debitor#20002Fourthe.G.-forthcontact.owner by system and assume }",
"{ grant perm view on hs_office_debitor#20002Fourthe.G.-forthcontact to role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant by system and assume }",
"{ grant role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant to role hs_office_contact#forthcontact.admin by system and assume }",
"{ grant role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant to role hs_office_debitor#20002Fourthe.G.-forthcontact.admin by system and assume }",
"{ grant role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant to role hs_office_partner#Fourthe.G.-forthcontact.admin by system and assume }",
"{ grant role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant to role hs_office_person#Fourthe.G..admin by system and assume }",
"{ grant role hs_office_partner#Fourthe.G.-forthcontact.tenant to role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant by system and assume }",
"{ grant role hs_office_contact#forthcontact.tenant to role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant by system and assume }",
"{ grant role hs_office_person#Fourthe.G..tenant to role hs_office_debitor#20002Fourthe.G.-forthcontact.tenant by system and assume }",
null));
}
private void assertThatDebitorIsPersisted(final HsOfficeDebitorEntity saved) {
final var found = debitorRepo.findByUuid(saved.getUuid());
assertThat(found).isNotEmpty().get().usingRecursiveComparison().isEqualTo(saved);
}
}
@Nested
class FindByOptionalName {
@Test
public void globalAdmin_withoutAssumedRole_canViewAllDebitors() {
// given
context("superuser-alex@hostsharing.net");
// when
final var result = debitorRepo.findDebitorByOptionalNameLike(null);
// then
allTheseDebitorsAreReturned(
result,
"debitor(10001: First GmbH)",
"debitor(10002: Second e.K.)",
"debitor(10003: Third OHG)");
}
@ParameterizedTest
@ValueSource(strings = {
"hs_office_partner#FirstGmbH-firstcontact.admin",
"hs_office_person#FirstGmbH.admin",
"hs_office_contact#firstcontact.admin",
})
public void relatedPersonAdmin_canViewRelatedDebitors(final String assumedRole) {
// given:
context("superuser-alex@hostsharing.net", assumedRole);
// when:
final var result = debitorRepo.findDebitorByOptionalNameLike(null);
// then:
exactlyTheseDebitorsAreReturned(result, "debitor(10001: First GmbH)");
}
@Test
public void unrelatedUser_canNotViewAnyDebitor() {
// given:
context("selfregistered-test-user@hostsharing.org");
// when:
final var result = debitorRepo.findDebitorByOptionalNameLike(null);
// then:
assertThat(result).isEmpty();
}
}
@Nested
class FindByDebitorNumberLike {
@Test
public void globalAdmin_withoutAssumedRole_canViewAllDebitors() {
// given
context("superuser-alex@hostsharing.net");
// when
final var result = debitorRepo.findDebitorByDebitorNumber(10003);
// then
exactlyTheseDebitorsAreReturned(result, "debitor(10003: Third OHG)");
}
}
@Nested
class FindByNameLike {
@Test
public void globalAdmin_withoutAssumedRole_canViewAllDebitors() {
// given
context("superuser-alex@hostsharing.net");
// when
final var result = debitorRepo.findDebitorByOptionalNameLike("third contact");
// then
exactlyTheseDebitorsAreReturned(result, "debitor(10003: Third OHG)");
}
}
@Nested
class UpdateDebitor {
@Test
public void hostsharingAdmin_withoutAssumedRole_canUpdateArbitraryDebitor() {
// given
context("superuser-alex@hostsharing.net");
final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "fifth contact");
assertThatDebitorIsVisibleForUserWithRole(
givenDebitor,
"hs_office_partner#Fourthe.G.-forthcontact.admin");
assertThatDebitorActuallyInDatabase(givenDebitor);
final var givenNewContact = contactRepo.findContactByOptionalLabelLike("sixth contact").get(0);
final String givenNewVatId = "NEW-VAT-ID";
final String givenNewVatCountryCode = "NC";
final boolean givenNewVatBusiness = !givenDebitor.isVatBusiness();
// when
final var result = jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net");
givenDebitor.setBillingContact(rawReference(givenNewContact));
givenDebitor.setVatId(givenNewVatId);
givenDebitor.setVatCountryCode(givenNewVatCountryCode);
givenDebitor.setVatBusiness(givenNewVatBusiness);
return toCleanup(debitorRepo.save(givenDebitor));
});
// then
result.assertSuccessful();
assertThatDebitorIsVisibleForUserWithRole(
result.returnedValue(),
"global#global.admin");
assertThatDebitorIsVisibleForUserWithRole(
result.returnedValue(),
"hs_office_contact#sixthcontact.admin");
assertThatDebitorIsNotVisibleForUserWithRole(
result.returnedValue(),
"hs_office_contact#fifthcontact.admin");
}
@Test
public void partnerAdmin_canNotUpdateRelatedDebitor() {
// given
context("superuser-alex@hostsharing.net");
final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "eighth");
assertThatDebitorIsVisibleForUserWithRole(
givenDebitor,
"hs_office_partner#Fourthe.G.-forthcontact.admin");
assertThatDebitorActuallyInDatabase(givenDebitor);
// when
final var result = jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net", "hs_office_partner#Fourthe.G.-forthcontact.admin");
givenDebitor.setVatId("NEW-VAT-ID");
return debitorRepo.save(givenDebitor);
});
// then
result.assertExceptionWithRootCauseMessage(JpaSystemException.class,
"[403] Subject ", " is not allowed to update hs_office_debitor uuid");
}
@Test
public void contactAdmin_canNotUpdateRelatedDebitor() {
// given
context("superuser-alex@hostsharing.net");
final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "ninth");
assertThatDebitorIsVisibleForUserWithRole(
givenDebitor,
"hs_office_contact#ninthcontact.admin");
assertThatDebitorActuallyInDatabase(givenDebitor);
// when
final var result = jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net", "hs_office_contact#ninthcontact.admin");
givenDebitor.setVatId("NEW-VAT-ID");
return debitorRepo.save(givenDebitor);
});
// then
result.assertExceptionWithRootCauseMessage(JpaSystemException.class,
"[403] Subject ", " is not allowed to update hs_office_debitor uuid");
}
private void assertThatDebitorActuallyInDatabase(final HsOfficeDebitorEntity saved) {
final var found = debitorRepo.findByUuid(saved.getUuid());
assertThat(found).isNotEmpty().get().isNotSameAs(saved).usingRecursiveComparison().isEqualTo(saved);
}
private void assertThatDebitorIsVisibleForUserWithRole(
final HsOfficeDebitorEntity entity,
final String assumedRoles) {
jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net", assumedRoles);
assertThatDebitorActuallyInDatabase(entity);
}).assertSuccessful();
}
private void assertThatDebitorIsNotVisibleForUserWithRole(
final HsOfficeDebitorEntity entity,
final String assumedRoles) {
jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net", assumedRoles);
final var found = debitorRepo.findByUuid(entity.getUuid());
assertThat(found).isEmpty();
}).assertSuccessful();
}
}
@Nested
class DeleteByUuid {
@Test
public void globalAdmin_withoutAssumedRole_canDeleteAnyDebitor() {
// given
context("superuser-alex@hostsharing.net", null);
final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "tenth");
// when
final var result = jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net");
debitorRepo.deleteByUuid(givenDebitor.getUuid());
});
// then
result.assertSuccessful();
assertThat(jpaAttempt.transacted(() -> {
context("superuser-fran@hostsharing.net", null);
return debitorRepo.findByUuid(givenDebitor.getUuid());
}).assertSuccessful().returnedValue()).isEmpty();
}
@Test
public void relatedPerson_canNotDeleteTheirRelatedDebitor() {
// given
context("superuser-alex@hostsharing.net", null);
final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "eleventh");
// when
final var result = jpaAttempt.transacted(() -> {
context("person-Fourthe.G.@example.com");
assertThat(debitorRepo.findByUuid(givenDebitor.getUuid())).isPresent();
debitorRepo.deleteByUuid(givenDebitor.getUuid());
});
// then
result.assertExceptionWithRootCauseMessage(
JpaSystemException.class,
"[403] Subject ", " not allowed to delete hs_office_debitor");
assertThat(jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net");
return debitorRepo.findByUuid(givenDebitor.getUuid());
}).assertSuccessful().returnedValue()).isPresent(); // still there
}
@Test
public void deletingADebitorAlsoDeletesRelatedRolesAndGrants() {
// given
context("superuser-alex@hostsharing.net");
final var initialRoleNames = Array.from(roleNamesOf(rawRoleRepo.findAll()));
final var initialGrantNames = Array.from(grantDisplaysOf(rawGrantRepo.findAll()));
final var givenDebitor = givenSomeTemporaryDebitor("Fourth", "twelfth");
assertThat(rawRoleRepo.findAll().size()).as("precondition failed: unexpected number of roles created")
.isEqualTo(initialRoleNames.length + 3);
assertThat(rawGrantRepo.findAll().size()).as("precondition failed: unexpected number of grants created")
.isEqualTo(initialGrantNames.length + 12);
// when
final var result = jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net");
return debitorRepo.deleteByUuid(givenDebitor.getUuid());
});
// then
result.assertSuccessful();
assertThat(result.returnedValue()).isEqualTo(1);
assertThat(roleNamesOf(rawRoleRepo.findAll())).containsExactlyInAnyOrder(initialRoleNames);
assertThat(grantDisplaysOf(rawGrantRepo.findAll())).containsExactlyInAnyOrder(initialGrantNames);
}
}
private HsOfficePartnerEntity rawReference(final HsOfficePartnerEntity givenPartner) {
return em.getReference(HsOfficePartnerEntity.class, givenPartner.getUuid());
}
private HsOfficeContactEntity rawReference(final HsOfficeContactEntity givenContact) {
return em.getReference(HsOfficeContactEntity.class, givenContact.getUuid());
}
private HsOfficeDebitorEntity givenSomeTemporaryDebitor(final String partner, final String contact) {
return jpaAttempt.transacted(() -> {
context("superuser-alex@hostsharing.net");
final var givenPartner = partnerRepo.findPartnerByOptionalNameLike(partner).get(0);
final var givenContact = contactRepo.findContactByOptionalLabelLike(contact).get(0);
final var newDebitor = HsOfficeDebitorEntity.builder()
.uuid(UUID.randomUUID())
.debitorNumber(20000)
.partner(rawReference(givenPartner))
.billingContact(rawReference(givenContact))
.build();
toCleanup(newDebitor);
return debitorRepo.save(newDebitor);
}).assertSuccessful().returnedValue();
}
private HsOfficeDebitorEntity toCleanup(final HsOfficeDebitorEntity tempDebitor) {
tempDebitors.add(tempDebitor);
return tempDebitor;
}
@AfterEach
void cleanup() {
context("superuser-alex@hostsharing.net", null);
tempDebitors.forEach(tempDebitor -> {
System.out.println("DELETING temporary debitor: " + tempDebitor.toString());
debitorRepo.deleteByUuid(tempDebitor.getUuid());
});
}
void exactlyTheseDebitorsAreReturned(final List<HsOfficeDebitorEntity> actualResult, final String... debitorNames) {
assertThat(actualResult)
.extracting(HsOfficeDebitorEntity::toString)
.containsExactlyInAnyOrder(debitorNames);
}
void allTheseDebitorsAreReturned(final List<HsOfficeDebitorEntity> actualResult, final String... debitorNames) {
assertThat(actualResult)
.extracting(HsOfficeDebitorEntity::toString)
.contains(debitorNames);
}
}

View File

@ -87,6 +87,10 @@ class HsOfficePartnerControllerAcceptanceTest {
{
"person": { "tradeName": "Second e.K." },
"contact": { "label": "second contact" }
},
{
"person": { "personType": "SOLE_REPRESENTATION" },
"contact": { "label": "forth contact" }
}
]
"""));

View File

@ -70,6 +70,30 @@ class HsOfficePersonControllerAcceptanceTest {
.body("", lenientlyEquals("""
[
{
"personType": "LEGAL",
"tradeName": "First GmbH",
"givenName": null,
"familyName": null
},
{
"personType": "LEGAL",
"tradeName": "Second e.K.",
"givenName": "Miller",
"familyName": "Sandra"
},
{
"personType": "SOLE_REPRESENTATION",
"tradeName": "Third OHG",
"givenName": null,
"familyName": null
},
{
"personType": "SOLE_REPRESENTATION",
"tradeName": "Fourth e.G.",
"givenName": null,
"familyName": null
},
{
"personType": "NATURAL",
"tradeName": null,
"givenName": "Anita",
@ -81,24 +105,6 @@ class HsOfficePersonControllerAcceptanceTest {
"givenName": "Bessler",
"familyName": "Mel"
},
{
"personType": "LEGAL",
"tradeName": "First GmbH",
"givenName": null,
"familyName": null
},
{
"personType": "SOLE_REPRESENTATION",
"tradeName": "Third OHG",
"givenName": null,
"familyName": null
},
{
"personType": "LEGAL",
"tradeName": "Second e.K.",
"givenName": "Miller",
"familyName": "Sandra"
},
{
"personType": "NATURAL",
"tradeName": null,
@ -406,7 +412,7 @@ class HsOfficePersonControllerAcceptanceTest {
final var entity = personRepo.findByUuid(uuid);
final var count = personRepo.deleteByUuid(uuid);
System.out.println("DELETED temporary person: " + uuid + (count > 0 ? " successful" : " failed") +
(" (" + entity.map(HsOfficePersonEntity::getDisplayName).orElse("null") + ")"));
(" (" + entity.map(hsOfficePersonEntity -> hsOfficePersonEntity.toShortString()).orElse("null") + ")"));
}).assertSuccessful();
});
}

View File

@ -14,7 +14,7 @@ class HsOfficePersonEntityUnitTest {
.tradeName("some trade name")
.build();
final var actualDisplay = givenPersonEntity.getDisplayName();
final var actualDisplay = givenPersonEntity.toShortString();
assertThat(actualDisplay).isEqualTo("some trade name");
}
@ -26,7 +26,7 @@ class HsOfficePersonEntityUnitTest {
.givenName("some given name")
.build();
final var actualDisplay = givenPersonEntity.getDisplayName();
final var actualDisplay = givenPersonEntity.toShortString();
assertThat(actualDisplay).isEqualTo("some family name, some given name");
}

View File

@ -3,7 +3,9 @@ package net.hostsharing.hsadminng.hs.office.person;
import net.hostsharing.hsadminng.context.Context;
import net.hostsharing.hsadminng.context.ContextBasedTest;
import net.hostsharing.hsadminng.rbac.rbacgrant.RawRbacGrantRepository;
import net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantRepository;
import net.hostsharing.hsadminng.rbac.rbacrole.RawRbacRoleRepository;
import net.hostsharing.hsadminng.rbac.rbacrole.RbacRoleRepository;
import net.hostsharing.test.Array;
import net.hostsharing.test.JpaAttempt;
import org.junit.jupiter.api.AfterEach;
@ -266,7 +268,7 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest {
context("superuser-alex@hostsharing.net", null);
final var result = personRepo.findPersonByOptionalNameLike("some temporary person");
result.forEach(tempPerson -> {
System.out.println("DELETING temporary person: " + tempPerson.getDisplayName());
System.out.println("DELETING temporary person: " + tempPerson.toShortString());
personRepo.deleteByUuid(tempPerson.getUuid());
});
}
@ -293,7 +295,7 @@ class HsOfficePersonRepositoryIntegrationTest extends ContextBasedTest {
void allThesePersonsAreReturned(final List<HsOfficePersonEntity> actualResult, final String... personLabels) {
assertThat(actualResult)
.extracting(HsOfficePersonEntity::getDisplayName)
.extracting(hsOfficePersonEntity -> hsOfficePersonEntity.toShortString())
.contains(personLabels);
}
}