diff --git a/hawkbit-core/src/main/java/org/eclipse/hawkbit/security/HawkbitSecurityProperties.java b/hawkbit-core/src/main/java/org/eclipse/hawkbit/security/HawkbitSecurityProperties.java index 6fb7cc4f9e..8fd1b045c3 100644 --- a/hawkbit-core/src/main/java/org/eclipse/hawkbit/security/HawkbitSecurityProperties.java +++ b/hawkbit-core/src/main/java/org/eclipse/hawkbit/security/HawkbitSecurityProperties.java @@ -137,6 +137,10 @@ public static class Dos { * Maximum number of allowed groups per Rollout. */ private int maxRolloutGroupsPerRollout = 500; + /** + * Maximum number of allowed distinct target groups. + */ + private int maxTargetGroups = 100; /** * Maximum number of messages per ActionStatus */ diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/QuotaManagement.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/QuotaManagement.java index 9c1db1bd2e..06d6606cd7 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/QuotaManagement.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/QuotaManagement.java @@ -75,6 +75,11 @@ public interface QuotaManagement { */ int getMaxTargetsPerRolloutGroup(); + /** + * @return the maximum number of distinct target groups + */ + int getMaxTargetGroups(); + /** * @return the maximum number of target distribution set assignments resulting from a manual assignment */ diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/PropertiesQuotaManagement.java b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/PropertiesQuotaManagement.java index c9601ec669..b1e0abb050 100644 --- a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/PropertiesQuotaManagement.java +++ b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/PropertiesQuotaManagement.java @@ -82,6 +82,11 @@ public int getMaxTargetsPerRolloutGroup() { return securityProperties.getDos().getMaxTargetsPerRolloutGroup(); } + @Override + public int getMaxTargetGroups() { + return securityProperties.getDos().getMaxTargetGroups(); + } + @Override public int getMaxTargetDistributionSetAssignmentsPerManualAssignment() { return securityProperties.getDos().getMaxTargetDistributionSetAssignmentsPerManualAssignment(); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTargetManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTargetManagement.java index c3cac0816a..c302ba9221 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTargetManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/management/JpaTargetManagement.java @@ -19,6 +19,8 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; @@ -34,6 +36,7 @@ import jakarta.validation.constraints.NotEmpty; import lombok.extern.slf4j.Slf4j; +import org.eclipse.hawkbit.context.AccessContext; import org.eclipse.hawkbit.ql.jpa.QLSupport; import org.eclipse.hawkbit.repository.QuotaManagement; import org.eclipse.hawkbit.repository.TargetManagement; @@ -102,6 +105,50 @@ protected JpaTargetManagement( this.targetTagRepository = targetTagRepository; } + @Override + @Transactional + @Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY) + public JpaTarget create(final TargetManagement.Create create) { + assertTargetGroupQuota(Collections.singletonList(create.getGroup())); + return super.create(create); + } + + @Override + @Transactional + @Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY) + public List create(final Collection create) { + assertTargetGroupQuota(create.stream().map(TargetManagement.Create::getGroup).toList()); + return super.create(create); + } + + @Override + @Transactional + @Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY) + public JpaTarget update(final TargetManagement.Update update) { + try { + assertTargetGroupQuota(Collections.singletonList(update.getGroup())); + } catch (final Exception ex) { + // target existence check in order to throw EntityNotFound instead of AssignmentQuotaException if both applicable + getValid(update.getId()); + throw ex; + } + return super.update(update); + } + + @Override + @Transactional + @Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY) + public Map update(final Collection update) { + try { + assertTargetGroupQuota(update.stream().map(TargetManagement.Update::getGroup).toList()); + } catch (final Exception ex) { + // target existence check in order to throw EntityNotFound instead of AssignmentQuotaException if both applicable + get(update.stream().map(TargetManagement.Update::getId).toList()); + throw ex; + } + return super.update(update); + } + @Override public Map getControllerAttributes(final String controllerId) { return getMap(controllerId, JpaTarget_.controllerAttributes); @@ -339,6 +386,8 @@ public Target unassignType(final String controllerId) { @Transactional @Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY) public void assignTargetGroupWithRsql(String group, String rsql) { + // Quota check + assertTargetGroupQuota(Collections.singletonList(group)); // Switch back to UpdateAllQuery if switching back to hibernate. (EclipseLink does not work well with UpdateAllQuery) // EclipseLink: using subquery approach — applying predicate directly to the UPDATE root @@ -401,6 +450,9 @@ private void assignTargetGroupOnChunks(final String group, final String rsql) { @Transactional @Retryable(includes = ConcurrencyFailureException.class, maxRetriesString = Constants.RETRY_MAX, delayString = Constants.RETRY_DELAY) public void assignTargetsWithGroup(String group, List controllerIds) { + // Quota check + assertTargetGroupQuota(Collections.singletonList(group)); + final CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaUpdate criteriaQuery = cb.createCriteriaUpdate(JpaTarget.class); Root root = criteriaQuery.from(JpaTarget.class); @@ -552,4 +604,34 @@ private void throwEntityNotFoundExceptionIfTagDoesNotExist(final Long tagId) { throw new EntityNotFoundException(TargetTag.class, tagId); } } + + private void assertTargetGroupQuota(final Collection requested) { + final SortedSet wanted = requested.stream() + .filter(Objects::nonNull) + .collect(Collectors.toCollection(TreeSet::new)); + + if (wanted.isEmpty()) { + return; // no group(s), skip findDistinctGroups db call + } + + final long limit = quotaManagement.getMaxTargetGroups(); + if (limit <= 0) { + return; + } + + // one group, already present -> allowed + if (wanted.size() == 1 && jpaRepository.existsByGroup(wanted.first())) { + return; + } + final List existing = jpaRepository.findDistinctGroups(AccessContext.tenant()); + + existing.forEach(wanted::remove); + if (wanted.isEmpty()) { + return; // no growth -> allowed + } + + QuotaHelper.assertAssignmentQuota( + AccessContext.tenant(), wanted.size(), limit, "target group", "tenant", + tenant -> existing.size()); + } } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/repository/TargetRepository.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/repository/TargetRepository.java index db0f332f05..ccfa35f6a8 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/repository/TargetRepository.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/repository/TargetRepository.java @@ -110,4 +110,12 @@ void setAssignedAndInstalledDistributionSetAndUpdateStatus( */ @Query(value = "SELECT DISTINCT target_group FROM sp_target WHERE tenant = ?1 AND target_group IS NOT NULL", nativeQuery = true) List findDistinctGroups(@Param("tenant") String tenant); + + /** + * Checks if a target group is present + * + * @param group to check existence + * @return whether a target group is present or not + */ + boolean existsByGroup(String group); } \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/TargetGroupQuotaTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/TargetGroupQuotaTest.java new file mode 100644 index 0000000000..f86effbea8 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/management/TargetGroupQuotaTest.java @@ -0,0 +1,394 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.repository.jpa.management; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; + +import java.util.List; +import java.util.stream.IntStream; + +import org.eclipse.hawkbit.context.AccessContext; +import org.eclipse.hawkbit.repository.TargetManagement.Create; +import org.eclipse.hawkbit.repository.TargetManagement.Update; +import org.eclipse.hawkbit.repository.exception.AssignmentQuotaExceededException; +import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; +import org.eclipse.hawkbit.repository.jpa.AbstractJpaIntegrationTest; +import org.eclipse.hawkbit.repository.model.Target; +import org.eclipse.hawkbit.repository.test.util.SecurityContextSwitch; +import org.eclipse.hawkbit.security.HawkbitSecurityProperties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * Tests for the "distinct target groups per tenant" quota + * ({@code hawkbit.server.security.dos.maxTargetGroups}). + */ +class TargetGroupQuotaTest extends AbstractJpaIntegrationTest { + + private static final String GERMANY = "Germany"; + private static final String FRANCE = "France"; + private static final String SPAIN = "Spain"; + private static final String ITALY = "Italy"; + private static final String PORTUGAL = "Portugal"; + private static final String BELGIUM = "Belgium"; + + @Autowired + private HawkbitSecurityProperties securityProperties; + + private int originalLimit; + + @BeforeEach + void rememberLimit() { + originalLimit = securityProperties.getDos().getMaxTargetGroups(); + } + + @AfterEach + void restoreLimit() { + securityProperties.getDos().setMaxTargetGroups(originalLimit); + } + + /** + * create(Create) rejects a new group once the tenant is at the limit. + */ + @Test + void createSingleRejectsNewGroupBeyondLimit() { + seedGroups(GERMANY, FRANCE, SPAIN); + limit(3); + + assertThatExceptionOfType(AssignmentQuotaExceededException.class) + .isThrownBy(() -> targetManagement.create(Create.builder().controllerId("over-1").group(ITALY).build())); + } + + /** + * bulk create rejects a new group once the tenant is at the limit. + */ + @Test + void createBatchRejectsNewGroupBeyondLimit() { + seedGroups(GERMANY, FRANCE, SPAIN); + limit(3); + + final List creates = List.of( + Create.builder().controllerId("over-1").group(ITALY).build(), + Create.builder().controllerId("over-2").group(ITALY).build()); + assertThatExceptionOfType(AssignmentQuotaExceededException.class) + .isThrownBy(() -> targetManagement.create(creates)); + } + + /** + * update rejects a new group once the tenant is at the limit. + */ + @Test + void updateSingleRejectsNewGroupBeyondLimit() { + final Target plain = targetManagement.create(Create.builder().controllerId("plain").build()); + seedGroups(GERMANY, FRANCE, SPAIN); + limit(3); + + assertThatExceptionOfType(AssignmentQuotaExceededException.class) + .isThrownBy(() -> targetManagement.update(Update.builder().id(plain.getId()).group(ITALY).build())); + } + + /** + * bulk update rejects a new group once the tenant is at the limit. + */ + @Test + void updateBatchRejectsNewGroupBeyondLimit() { + final Target plain1 = targetManagement.create(Create.builder().controllerId("plain-1").build()); + final Target plain2 = targetManagement.create(Create.builder().controllerId("plain-2").build()); + seedGroups(GERMANY, FRANCE, SPAIN); + limit(3); + + final List updates = List.of( + Update.builder().id(plain1.getId()).group(ITALY).build(), + Update.builder().id(plain2.getId()).group(ITALY).build()); + assertThatExceptionOfType(AssignmentQuotaExceededException.class) + .isThrownBy(() -> targetManagement.update(updates)); + } + + /** + * assignTargetsWithGroup rejects a new group once the tenant is at the limit. + */ + @Test + void assignTargetsWithGroupRejectsNewGroupBeyondLimit() { + targetManagement.create(Create.builder().controllerId("plain").build()); + seedGroups(GERMANY, FRANCE, SPAIN); + limit(3); + + assertThatExceptionOfType(AssignmentQuotaExceededException.class) + .isThrownBy(() -> targetManagement.assignTargetsWithGroup(ITALY, List.of("plain"))); + } + + /** + * assignTargetGroupWithRsql rejects a new group once the tenant is at the limit (direct, non-negated path). + */ + @Test + void assignTargetGroupWithRsqlRejectsNewGroupBeyondLimit() { + targetManagement.create(Create.builder().controllerId("plain").build()); + seedGroups(GERMANY, FRANCE, SPAIN); + limit(3); + + assertThatExceptionOfType(AssignmentQuotaExceededException.class) + .isThrownBy(() -> targetManagement.assignTargetGroupWithRsql(ITALY, "controllerId==plain")); + } + + /** + * An existing group is assignable when the tenant sits exactly at the limit. + */ + @Test + void existingGroupIsAssignableWhenExactlyAtLimit() { + targetManagement.create(Create.builder().controllerId("plain").build()); + seedGroups(GERMANY, FRANCE, SPAIN); + limit(3); + + assertThatNoException().isThrownBy(() -> targetManagement.assignTargetsWithGroup(GERMANY, List.of("plain"))); + assertThat(groupsOfCurrentTenant()).containsExactlyInAnyOrder(GERMANY, FRANCE, SPAIN); + } + + /** + * An existing group is assignable when the tenant is already over the limit - the single-group fast path. + */ + @Test + void existingGroupIsAssignableWhenOverLimit() { + targetManagement.create(Create.builder().controllerId("plain").build()); + seedGroups(GERMANY, FRANCE, SPAIN, ITALY, PORTUGAL); + limit(3); // tenant now holds 5 groups against a limit of 3 + + assertThatNoException() + .isThrownBy(() -> targetManagement.create(Create.builder().controllerId("more").group(GERMANY).build())); + assertThat(groupsOfCurrentTenant()).containsExactlyInAnyOrder(GERMANY, FRANCE, SPAIN, ITALY, PORTUGAL); + } + + /** + * Several existing groups are assignable in one batch when the tenant is over the limit. Exercises the removeAll branch rather + * than the size==1 existsByGroup fast path. + */ + @Test + void multipleExistingGroupsAreAssignableWhenOverLimit() { + seedGroups(GERMANY, FRANCE, SPAIN, ITALY, PORTUGAL); + limit(3); + + final List creates = List.of( + Create.builder().controllerId("more-1").group(GERMANY).build(), + Create.builder().controllerId("more-2").group(FRANCE).build()); + assertThatNoException().isThrownBy(() -> targetManagement.create(creates)); + assertThat(groupsOfCurrentTenant()).containsExactlyInAnyOrder(GERMANY, FRANCE, SPAIN, ITALY, PORTUGAL); + } + + /** + * The unassign paths pass a null group and must not fail at the limit. Guards against a List.of(group) regression, which would + * throw NullPointerException + */ + @Test + void nullGroupUnassignPathsAreAllowedAtLimit() { + targetManagement.create(Create.builder().controllerId("unassign-1").group(GERMANY).build()); + targetManagement.create(Create.builder().controllerId("unassign-2").group(GERMANY).build()); + seedGroups(GERMANY, FRANCE, SPAIN); + limit(3); + + assertThatNoException().isThrownBy(() -> targetManagement.assignTargetsWithGroup(null, List.of("unassign-1"))); + assertThatNoException().isThrownBy(() -> targetManagement.assignTargetGroupWithRsql(null, "controllerId==unassign-2")); + } + + /** + * A limit of zero means unlimited. + */ + @Test + void zeroLimitDisablesEnforcement() { + seedGroups(GERMANY, FRANCE, SPAIN); + limit(0); + + assertThatNoException() + .isThrownBy(() -> targetManagement.create(Create.builder().controllerId("unlimited").group(ITALY).build())); + assertThat(groupsOfCurrentTenant()).contains(ITALY); + } + + /** + * A negative limit means unlimited. + */ + @Test + void negativeLimitDisablesEnforcement() { + seedGroups(GERMANY, FRANCE, SPAIN); + limit(-1); + + assertThatNoException() + .isThrownBy(() -> targetManagement.create(Create.builder().controllerId("unlimited").group(ITALY).build())); + assertThat(groupsOfCurrentTenant()).contains(ITALY); + } + + /** + * An RSQL containing a negation routes through assignTargetGroupOnChunks and must be quota checked too. + */ + @Test + void chunkedRsqlPathRejectsNewGroupBeyondLimit() { + //Assumptions.assumeTrue(Jpa.JPA_VENDOR == Jpa.JpaVendor.ECLIPSELINK, "chunked path only exists on EclipseLink"); + targetManagement.create(Create.builder().controllerId("plain").build()); + seedGroups(GERMANY, FRANCE, SPAIN); + limit(3); + + // "!=" makes containsNegation true -> assignTargetGroupOnChunks + assertThatExceptionOfType(AssignmentQuotaExceededException.class) + .isThrownBy(() -> targetManagement.assignTargetGroupWithRsql(ITALY, "controllerId!=doesNotExist")); + } + + /** + * The chunked path still assigns an existing group when the tenant is over the limit. + */ + @Test + void chunkedRsqlPathAllowsExistingGroupWhenOverLimit() { + //Assumptions.assumeTrue(Jpa.JPA_VENDOR == Jpa.JpaVendor.ECLIPSELINK, "chunked path only exists on EclipseLink"); + targetManagement.create(Create.builder().controllerId("plain").build()); + seedGroups(GERMANY, FRANCE, SPAIN, ITALY, PORTUGAL); + limit(3); + + assertThatNoException() + .isThrownBy(() -> targetManagement.assignTargetGroupWithRsql(GERMANY, "controllerId!=doesNotExist")); + assertThat(targetManagement.getByControllerId("plain").getGroup()).isEqualTo(GERMANY); + } + + /** + * A batch create of many targets sharing one new group consumes exactly one group. + */ + @Test + void batchCreateSharingOneNewGroupConsumesOneGroup() { + seedGroups(GERMANY, FRANCE); + limit(3); + + final List creates = IntStream.range(0, 10) + . mapToObj(i -> Create.builder().controllerId("bulk-" + i).group(SPAIN).build()) + .toList(); + assertThatNoException().isThrownBy(() -> targetManagement.create(creates)); + assertThat(groupsOfCurrentTenant()).containsExactlyInAnyOrder(GERMANY, FRANCE, SPAIN); + } + + /** + * A batch carrying k distinct new groups is asserted once and must be rejected when it would land at n + k, not merely n + 1. + * With one existing group and a limit of three, three new groups would land at four. + */ + @Test + void batchCreateWithDistinctNewGroupsIsAssertedOnce() { + seedGroups(GERMANY); + limit(3); + + final List creates = List.of( + Create.builder().controllerId("k-1").group(FRANCE).build(), + Create.builder().controllerId("k-2").group(SPAIN).build(), + Create.builder().controllerId("k-3").group(ITALY).build()); + assertThatExceptionOfType(AssignmentQuotaExceededException.class) + .isThrownBy(() -> targetManagement.create(creates)); + assertThat(groupsOfCurrentTenant()).containsExactly(GERMANY); + } + + /** + * The same batch shape is allowed when n + k lands exactly on the limit. + */ + @Test + void batchCreateWithDistinctNewGroupsIsAllowedExactlyAtLimit() { + seedGroups(GERMANY); + limit(3); + + final List creates = List.of( + Create.builder().controllerId("k-1").group(FRANCE).build(), + Create.builder().controllerId("k-2").group(SPAIN).build()); + assertThatNoException().isThrownBy(() -> targetManagement.create(creates)); + assertThat(groupsOfCurrentTenant()).containsExactlyInAnyOrder(GERMANY, FRANCE, SPAIN); + } + + /** + * Groups belonging to another tenant do not count towards this tenant's cardinality. + */ + @Test + void otherTenantGroupsDoNotCountTowardsThisTenant() { + limit(0); + // uppercase tenant name so that this test isolates tenant scoping and is not confounded by the casing defect + SecurityContextSwitch.runAs( + SecurityContextSwitch.withTenantAndUserAndAllPermissions("OTHERTENANT", "otheruser"), + () -> { + targetManagement.create(Create.builder().controllerId("other-1").group(ITALY).build()); + targetManagement.create(Create.builder().controllerId("other-2").group(PORTUGAL).build()); + targetManagement.create(Create.builder().controllerId("other-3").group(BELGIUM).build()); + }); + + seedGroups(GERMANY, FRANCE); + limit(3); + + // this tenant holds 2 groups, so a third is allowed - it would be rejected if the other tenant's 3 groups were counted + assertThatNoException() + .isThrownBy(() -> targetManagement.create(Create.builder().controllerId("mine").group(SPAIN).build())); + assertThat(groupsOfCurrentTenant()).containsExactlyInAnyOrder(GERMANY, FRANCE, SPAIN); + } + + + /** + * Rejection surfaces as AssignmentQuotaExceededException, carrying the SP_QUOTA_EXCEEDED error. + */ + @Test + void rejectionThrowsAssignmentQuotaExceededException() { + seedGroups(GERMANY, FRANCE, SPAIN); + limit(3); + + assertThatExceptionOfType(AssignmentQuotaExceededException.class) + .isThrownBy(() -> targetManagement.create(Create.builder().controllerId("over").group(ITALY).build())) + .satisfies(e -> assertThat(e.getError().getKey()).isEqualTo("hawkbit.server.error.quota.tooManyEntries")); + } + + /** + * Updating a nonexistent target with a new group while at the limit therefore reports NOT FOUND rather than quota-exceeded. + */ + @Test + void updateOfNonexistentTargetAtLimitReportsQuotaExceededRatherThanNotFound() { + seedGroups(GERMANY, FRANCE, SPAIN); + limit(3); + + assertThatExceptionOfType(EntityNotFoundException.class) + .isThrownBy(() -> targetManagement.update(Update.builder().id(123456789L).group(ITALY).build())); + } + + /** + * The same ordering guard for the batch overload. update(Collection) resolves its ids through findAllById(ids, true), so a batch + * containing a nonexistent id while at the limit must report NOT FOUND rather than quota-exceeded. + */ + @Test + void updateBatchOfNonexistentTargetAtLimitReportsNotFoundRatherThanQuotaExceeded() { + final Target plain = targetManagement.create(Create.builder().controllerId("plain").build()); + seedGroups(GERMANY, FRANCE, SPAIN); + limit(3); + + final List updates = List.of( + Update.builder().id(plain.getId()).group(ITALY).build(), + Update.builder().id(123456789L).group(ITALY).build()); + assertThatExceptionOfType(EntityNotFoundException.class) + .isThrownBy(() -> targetManagement.update(updates)); + } + + private void limit(final int limit) { + securityProperties.getDos().setMaxTargetGroups(limit); + } + + /** + * Seeds one target per group with enforcement disabled, so that over-limit states are reachable. Leaves the limit at 0 - callers + * set the limit under test afterwards. + */ + private void seedGroups(final String... groups) { + limit(0); + for (final String group : groups) { + targetManagement.create(Create.builder().controllerId("seed-" + group).group(group).build()); + } + } + + /** + * findGroups is native and needs the stored (upper-cased) tenant, see design 6.1. + */ + private List groupsOfCurrentTenant() { + return targetManagement.findGroups(AccessContext.tenant().toUpperCase()); + } +}