This is an automated email from the ASF dual-hosted git repository.
smolnar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/knox.git
The following commit(s) were added to refs/heads/master by this push:
new 7ecd609b9 KNOX-3035 - Limit response group header contents by size.
(#1101)
7ecd609b9 is described below
commit 7ecd609b9fc8ebd9aa84e5de560fe5ff0130357b
Author: Sandor Molnar <[email protected]>
AuthorDate: Wed Oct 22 13:54:55 2025 +0200
KNOX-3035 - Limit response group header contents by size. (#1101)
Size-based group header partitioning takes precedence over length-based
partitioning, iff, size-based partitioning is configured to a non-negative
number.
---
.../gateway/service/auth/AbstractAuthResource.java | 44 ++----
.../gateway/service/auth/PreAuthResourceTest.java | 18 +--
.../gateway/dispatch/ConfigurableDispatch.java | 54 +++----
.../org/apache/knox/gateway/util/GroupUtils.java | 109 ++++++++++++++
.../apache/knox/gateway/util/GroupUtilsTest.java | 166 +++++++++++++++++++++
5 files changed, 321 insertions(+), 70 deletions(-)
diff --git
a/gateway-service-auth/src/main/java/org/apache/knox/gateway/service/auth/AbstractAuthResource.java
b/gateway-service-auth/src/main/java/org/apache/knox/gateway/service/auth/AbstractAuthResource.java
index 61c9aed76..f59919584 100644
---
a/gateway-service-auth/src/main/java/org/apache/knox/gateway/service/auth/AbstractAuthResource.java
+++
b/gateway-service-auth/src/main/java/org/apache/knox/gateway/service/auth/AbstractAuthResource.java
@@ -19,13 +19,12 @@ package org.apache.knox.gateway.service.auth;
import org.apache.knox.gateway.i18n.messages.MessagesFactory;
import org.apache.knox.gateway.security.SubjectUtils;
+import org.apache.knox.gateway.util.GroupUtils;
import javax.security.auth.Subject;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.core.Response;
-import java.util.ArrayList;
-import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
@@ -39,7 +38,9 @@ import static javax.ws.rs.core.Response.status;
public abstract class AbstractAuthResource {
public static final String AUTH_ACTOR_ID_HEADER_NAME =
"preauth.auth.header.actor.id.name";
public static final String AUTH_ACTOR_GROUPS_HEADER_PREFIX =
"preauth.auth.header.actor.groups.prefix";
- public static final String GROUP_FILTER_PATTERN =
"preauth.group.filter.pattern";
+ public static final String GROUP_HEADER_LENGTH_LIMIT =
"preauth.auth.header.groups.length.limit";
+ public static final String GROUP_HEADER_SIZE_LIMIT =
"preauth.auth.header.groups.size.limit";
+ private static final String GROUP_FILTER_PATTERN =
"preauth.group.filter.pattern";
static final AuthMessages LOG = MessagesFactory.get(AuthMessages.class);
@@ -47,16 +48,21 @@ public abstract class AbstractAuthResource {
static final String DEFAULT_AUTH_ACTOR_GROUPS_HEADER_PREFIX =
"X-Knox-Actor-Groups";
static final Pattern DEFAULT_GROUP_FILTER_PATTERN = Pattern.compile(".*");
- protected static final int MAX_HEADER_LENGTH = 1000;
- protected static final String ACTOR_GROUPS_HEADER_FORMAT = "%s-%d";
+ private static final String DEFAULT_GROUP_HEADER_LENGTH_LIMIT = "1000";
+ private static final String DEFAULT_GROUP_HEADER_SIZE_LIMIT = "-1"; //
turned off by default, to be backward compatible
+ private static final String ACTOR_GROUPS_HEADER_FORMAT = "%s-%d";
protected String authHeaderActorIDName;
protected String authHeaderActorGroupsPrefix;
+ private int groupHeaderLengthLimit;
+ private int groupHeaderSizeLimit;
protected Pattern groupFilterPattern;
protected void initialize() {
authHeaderActorIDName = getInitParameter(AUTH_ACTOR_ID_HEADER_NAME,
DEFAULT_AUTH_ACTOR_ID_HEADER_NAME);
authHeaderActorGroupsPrefix =
getInitParameter(AUTH_ACTOR_GROUPS_HEADER_PREFIX,
DEFAULT_AUTH_ACTOR_GROUPS_HEADER_PREFIX);
+ groupHeaderLengthLimit =
Integer.parseInt(getInitParameter(GROUP_HEADER_LENGTH_LIMIT,
DEFAULT_GROUP_HEADER_LENGTH_LIMIT));
+ groupHeaderSizeLimit =
Integer.parseInt(getInitParameter(GROUP_HEADER_SIZE_LIMIT,
DEFAULT_GROUP_HEADER_SIZE_LIMIT));
final String groupFilterPatternString =
getInitParameter(GROUP_FILTER_PATTERN, null);
groupFilterPattern = groupFilterPatternString == null ?
DEFAULT_GROUP_FILTER_PATTERN : Pattern.compile(groupFilterPatternString);
}
@@ -84,10 +90,10 @@ public abstract class AbstractAuthResource {
// Populate actor groups headers
final Set<String> matchingGroupNames = subject == null ?
Collections.emptySet()
- : SubjectUtils.getGroupPrincipals(subject).stream().filter(group ->
groupFilterPattern.matcher(group.getName()).matches()).map(group ->
group.getName())
- .collect(Collectors.toSet());
+ : SubjectUtils.getGroupPrincipals(subject).stream().filter(group
-> groupFilterPattern.matcher(group.getName()).matches()).map(group ->
group.getName())
+ .collect(Collectors.toSet());
if (!matchingGroupNames.isEmpty()) {
- final List<String> groupStrings = getGroupStrings(matchingGroupNames);
+ final List<String> groupStrings =
GroupUtils.getGroupStrings(matchingGroupNames, groupHeaderLengthLimit,
groupHeaderSizeLimit);
for (int i = 0; i < groupStrings.size(); i++) {
getResponse().addHeader(String.format(Locale.ROOT,
ACTOR_GROUPS_HEADER_FORMAT, authHeaderActorGroupsPrefix, i + 1),
groupStrings.get(i));
}
@@ -95,26 +101,4 @@ public abstract class AbstractAuthResource {
return ok().build();
}
- private List<String> getGroupStrings(Collection<String> groupNames) {
- if (groupNames.isEmpty()) {
- return Collections.emptyList();
- }
- List<String> groupStrings = new ArrayList<>();
- StringBuilder sb = new StringBuilder();
- for (String groupName : groupNames) {
- if (sb.length() + groupName.length() > MAX_HEADER_LENGTH) {
- groupStrings.add(sb.toString());
- sb = new StringBuilder();
- }
- if (sb.length() > 0) {
- sb.append(',');
- }
- sb.append(groupName);
- }
- if (sb.length() > 0) {
- groupStrings.add(sb.toString());
- }
- return groupStrings;
- }
-
}
diff --git
a/gateway-service-auth/src/test/java/org/apache/knox/gateway/service/auth/PreAuthResourceTest.java
b/gateway-service-auth/src/test/java/org/apache/knox/gateway/service/auth/PreAuthResourceTest.java
index 4cb3b12e3..01ca3c5e2 100644
---
a/gateway-service-auth/src/test/java/org/apache/knox/gateway/service/auth/PreAuthResourceTest.java
+++
b/gateway-service-auth/src/test/java/org/apache/knox/gateway/service/auth/PreAuthResourceTest.java
@@ -44,7 +44,6 @@ public class PreAuthResourceTest {
private static final String USER_NAME = "test-username";
private ServletContext context;
- private HttpServletRequest request;
private HttpServletResponse response;
private final Subject subject = new Subject();
@@ -53,15 +52,15 @@ public class PreAuthResourceTest {
subject.getPrincipals().add(new PrimaryPrincipal(USER_NAME));
}
- private void configureCommonExpectations(String actorIdHeaderName, String
groupsHeaderPrefix) {
- configureCommonExpectations(actorIdHeaderName, groupsHeaderPrefix,
Collections.emptySet());
+ private void configureCommonExpectations(String actorIdHeaderName) {
+ configureCommonExpectations(actorIdHeaderName, null,
Collections.emptySet());
}
private void configureCommonExpectations(String actorIdHeaderName, String
groupsHeaderPrefix, Collection<String> groups) {
context = EasyMock.createNiceMock(ServletContext.class);
EasyMock.expect(context.getInitParameter(PreAuthResource.AUTH_ACTOR_ID_HEADER_NAME)).andReturn(actorIdHeaderName).anyTimes();
EasyMock.expect(context.getInitParameter(PreAuthResource.AUTH_ACTOR_GROUPS_HEADER_PREFIX)).andReturn(groupsHeaderPrefix).anyTimes();
- request = EasyMock.createNiceMock(HttpServletRequest.class);
+ final HttpServletRequest request =
EasyMock.createNiceMock(HttpServletRequest.class);
response = EasyMock.createNiceMock(HttpServletResponse.class);
if (SubjectUtils.getPrimaryPrincipalName(subject) != null) {
@@ -75,12 +74,13 @@ public class PreAuthResourceTest {
final int groupStringSize = calculateGroupStringSize(groups);
final int expectedGroupHeaderCount = groupStringSize / 1000 + 1;
final String expectedGroupsHeaderPrefix = (groupsHeaderPrefix == null ?
PreAuthResource.DEFAULT_AUTH_ACTOR_GROUPS_HEADER_PREFIX : groupsHeaderPrefix)
- + "-";
+ + "-";
for (int i = 1; i <= expectedGroupHeaderCount; i++) {
response.addHeader(EasyMock.eq(expectedGroupsHeaderPrefix + i),
EasyMock.anyString());
EasyMock.expectLastCall();
}
}
+
EasyMock.replay(context, request, response);
}
@@ -94,17 +94,17 @@ public class PreAuthResourceTest {
@Test
public void testSubjectWithoutPrimaryPrincipalReturnsUnauthorized() throws
Exception {
subject.getPrincipals().clear();
- configureCommonExpectations(null, null);
+ configureCommonExpectations(null);
final PreAuthResource preAuthResource = new PreAuthResource();
preAuthResource.context = context;
preAuthResource.response = response;
final Response response = executeResourceWithSubject(preAuthResource);
- assertEquals(response.getStatus(), HttpServletResponse.SC_UNAUTHORIZED);
+ assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
}
@Test
public void testPopulatingDefaultActorIdHeaderNoGroups() throws Exception {
- configureCommonExpectations(null, null);
+ configureCommonExpectations(null);
final PreAuthResource preAuthResource = new PreAuthResource();
preAuthResource.context = context;
preAuthResource.response = response;
@@ -114,7 +114,7 @@ public class PreAuthResourceTest {
@Test
public void testPopulatingCustomActorIdHeaderNoGroups() throws Exception {
- configureCommonExpectations("customActorId", null);
+ configureCommonExpectations("customActorId");
final PreAuthResource preAuthResource = new PreAuthResource();
preAuthResource.context = context;
preAuthResource.response = response;
diff --git
a/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/ConfigurableDispatch.java
b/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/ConfigurableDispatch.java
index d9a469783..08458e3cb 100644
---
a/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/ConfigurableDispatch.java
+++
b/gateway-spi/src/main/java/org/apache/knox/gateway/dispatch/ConfigurableDispatch.java
@@ -22,7 +22,9 @@ import org.apache.http.client.methods.HttpUriRequest;
import org.apache.knox.gateway.audit.api.ActionOutcome;
import org.apache.knox.gateway.config.Configure;
import org.apache.knox.gateway.config.Default;
+import org.apache.knox.gateway.security.GroupPrincipal;
import org.apache.knox.gateway.security.SubjectUtils;
+import org.apache.knox.gateway.util.GroupUtils;
import org.apache.knox.gateway.util.StringUtils;
import javax.security.auth.Subject;
@@ -40,9 +42,7 @@ import java.util.HashSet;
import java.util.HashMap;
import java.util.Optional;
import java.util.List;
-import java.util.Collection;
import java.util.Locale;
-import java.util.ArrayList;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -62,18 +62,20 @@ public class ConfigurableDispatch extends DefaultDispatch
implements SyncDispatc
private boolean shouldIncludePrincipalAndGroups;
private String actorIdHeaderName = DEFAULT_AUTH_ACTOR_ID_HEADER_NAME;
private String actorGroupsHeaderPrefix =
DEFAULT_AUTH_ACTOR_GROUPS_HEADER_PREFIX;
+ private int groupHeaderLengthLimit =
Integer.parseInt(DEFAULT_GROUP_HEADER_LENGTH_LIMIT);
+ private int groupHeaderSizeLimit =
Integer.parseInt(DEFAULT_GROUP_HEADER_SIZE_LIMIT);
private String groupFilterPattern = DEFAULT_GROUP_FILTER_PATTERN;
static final String DEFAULT_AUTH_ACTOR_ID_HEADER_NAME = "X-Knox-Actor-ID";
static final String DEFAULT_AUTH_ACTOR_GROUPS_HEADER_PREFIX =
"X-Knox-Actor-Groups";
+ static final String DEFAULT_GROUP_HEADER_LENGTH_LIMIT = "1000";
+ static final String DEFAULT_GROUP_HEADER_SIZE_LIMIT = "-1"; // turned off by
default, to be backward compatible
static final String DEFAULT_GROUP_FILTER_PATTERN = ".*";
static final String DEFAULT_ARE_USERS_GROUPS_HEADER_INCLUDED = "false";
- protected static final int MAX_HEADER_LENGTH = 1000;
protected static final String ACTOR_GROUPS_HEADER_FORMAT = "%s-%d";
protected Pattern groupPattern =
Pattern.compile(DEFAULT_GROUP_FILTER_PATTERN);
-
private Set<String> convertCommaDelimitedHeadersToSet(String headers) {
return headers == null ? Collections.emptySet(): new
HashSet<>(Arrays.asList(headers.split("\\s*,\\s*")));
}
@@ -126,8 +128,8 @@ public class ConfigurableDispatch extends DefaultDispatch
implements SyncDispatc
if (setCookieHeader.isPresent()) {
final String[] setCookieHeaderParts = setCookieHeader.get().split(":");
responseExcludeSetCookieHeaderDirectives = setCookieHeaderParts.length >
1
- ? new
HashSet<>(Arrays.asList(setCookieHeaderParts[1].split(";"))).stream().map(e ->
e.trim()).collect(Collectors.toSet())
- : EXCLUDE_SET_COOKIES_DEFAULT;
+ ? new
HashSet<>(Arrays.asList(setCookieHeaderParts[1].split(";"))).stream().map(e ->
e.trim()).collect(Collectors.toSet())
+ : EXCLUDE_SET_COOKIES_DEFAULT;
} else {
/* Exclude headers list is defined but we don't have set-cookie in the
list,
by default prevent these cookies from leaking */
@@ -162,6 +164,16 @@ public class ConfigurableDispatch extends DefaultDispatch
implements SyncDispatc
this.actorGroupsHeaderPrefix = actorGroupsHeaderPrefix;
}
+ @Configure
+ public void
setGroupHeaderLengthLimit(@Default(DEFAULT_GROUP_HEADER_LENGTH_LIMIT) int
groupHeaderLengthLimit) {
+ this.groupHeaderLengthLimit = groupHeaderLengthLimit;
+ }
+
+ @Configure
+ public void
setGroupHeaderSizeLimit(@Default(DEFAULT_GROUP_HEADER_SIZE_LIMIT) int
groupHeaderSizeLimit) {
+ this.groupHeaderSizeLimit = groupHeaderSizeLimit;
+ }
+
@Configure
public void setGroupFilterPattern(@Default(DEFAULT_GROUP_FILTER_PATTERN)
String groupFilterPattern) {
this.groupFilterPattern = groupFilterPattern;
@@ -189,7 +201,7 @@ public class ConfigurableDispatch extends DefaultDispatch
implements SyncDispatc
}
private Map<String, String> addPrincipalAndGroups() {
- final Map<String, String> headers = new ConcurrentHashMap();
+ final Map<String, String> headers = new ConcurrentHashMap<>();
final Subject subject = SubjectUtils.getCurrentSubject();
final String primaryPrincipalName = subject == null ? null :
SubjectUtils.getPrimaryPrincipalName(subject);
@@ -202,10 +214,12 @@ public class ConfigurableDispatch extends DefaultDispatch
implements SyncDispatc
// Populate actor groups headers
final Set<String> matchingGroupNames = subject == null ?
Collections.emptySet()
- : SubjectUtils.getGroupPrincipals(subject).stream().filter(group
-> groupPattern.matcher(group.getName()).matches()).map(group ->
group.getName())
+ : SubjectUtils.getGroupPrincipals(subject).stream()
+ .filter(group -> groupPattern.matcher(group.getName()).matches())
+ .map(GroupPrincipal::getName)
.collect(Collectors.toSet());
if (!matchingGroupNames.isEmpty()) {
- final List<String> groupStrings = getGroupStrings(matchingGroupNames);
+ final List<String> groupStrings =
GroupUtils.getGroupStrings(matchingGroupNames, groupHeaderLengthLimit,
groupHeaderSizeLimit);
for (int i = 0; i < groupStrings.size(); i++) {
headers.put(String.format(Locale.ROOT, ACTOR_GROUPS_HEADER_FORMAT,
actorGroupsHeaderPrefix, i + 1), groupStrings.get(i));
}
@@ -213,28 +227,6 @@ public class ConfigurableDispatch extends DefaultDispatch
implements SyncDispatc
return headers;
}
- private List<String> getGroupStrings(final Collection<String> groupNames) {
- if (groupNames.isEmpty()) {
- return Collections.emptyList();
- }
- List<String> groupStrings = new ArrayList<>();
- StringBuilder sb = new StringBuilder();
- for (String groupName : groupNames) {
- if (sb.length() + groupName.length() > MAX_HEADER_LENGTH) {
- groupStrings.add(sb.toString());
- sb = new StringBuilder();
- }
- if (sb.length() > 0) {
- sb.append(',');
- }
- sb.append(groupName);
- }
- if (sb.length() > 0) {
- groupStrings.add(sb.toString());
- }
- return groupStrings;
- }
-
@Override
public Set<String> getOutboundResponseExcludeHeaders() {
return responseExcludeHeaders == null ? Collections.emptySet() :
responseExcludeHeaders;
diff --git
a/gateway-spi/src/main/java/org/apache/knox/gateway/util/GroupUtils.java
b/gateway-spi/src/main/java/org/apache/knox/gateway/util/GroupUtils.java
new file mode 100644
index 000000000..db2d28277
--- /dev/null
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/util/GroupUtils.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.util;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+public class GroupUtils {
+
+ public static List<String> getGroupStrings(Collection<String> groupNames,
final int lengthLimit, final int sizeLimitBytes) {
+ if (groupNames.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ // Defensive copy to isolate from concurrent modifications
+ final List<String> safeGroupNames = new ArrayList<>(groupNames);
+
+ if (sizeLimitBytes > 0) {
+ return getGroupStringsBySize(safeGroupNames, sizeLimitBytes);
+ } else {
+ return getGroupStringsByLength(safeGroupNames, lengthLimit);
+ }
+ }
+
+ private static List<String> getGroupStringsBySize(Collection<String>
groupNames, int sizeLimitBytes) {
+ final List<String> groupStrings = new ArrayList<>();
+ StringBuilder sb = new StringBuilder();
+ int currentSize = 0; // UTF-8 byte count
+
+ for (String groupName : groupNames) {
+ int commaBytes = sb.length() > 0 ? 1 : 0; // comma between groups
+ int groupBytes = groupName.getBytes(StandardCharsets.UTF_8).length;
+ int projectedSize = currentSize + commaBytes + groupBytes;
+
+ if (projectedSize > sizeLimitBytes) {
+ saveGroups(groupStrings, sb);
+ sb = new StringBuilder();
+ currentSize = 0;
+ }
+
+ currentSize = addCommaIfNeeded(sb, currentSize);
+ sb.append(groupName);
+ currentSize += groupBytes;
+ }
+
+ if (sb.length() > 0) {
+ saveGroups(groupStrings, sb);
+ }
+
+ return groupStrings;
+ }
+
+ private static void saveGroups(final List<String> groupStrings, final
StringBuilder sb) {
+ final String groups = sb.toString();
+ if (StringUtils.isNotBlank(groups)) {
+ groupStrings.add(groups);
+ }
+ }
+
+ private static int addCommaIfNeeded(final StringBuilder sb, int
currentSize) {
+ if (sb.length() > 0) {
+ sb.append(',');
+ return currentSize + 1; // comma byte
+ } else {
+ return currentSize;
+ }
+ }
+
+ private static List<String> getGroupStringsByLength(Collection<String>
groupNames, int lengthLimit) {
+ final List<String> groupStrings = new ArrayList<>();
+ StringBuilder sb = new StringBuilder();
+
+ for (String groupName : groupNames) {
+ int commaChars = sb.length() > 0 ? 1 : 0;
+ if (sb.length() + groupName.length() + commaChars > lengthLimit) {
+ saveGroups(groupStrings, sb);
+ sb = new StringBuilder();
+ }
+ addCommaIfNeeded(sb, 0);
+ sb.append(groupName);
+ }
+
+ if (sb.length() > 0) {
+ saveGroups(groupStrings, sb);
+ }
+
+ return groupStrings;
+ }
+}
diff --git
a/gateway-spi/src/test/java/org/apache/knox/gateway/util/GroupUtilsTest.java
b/gateway-spi/src/test/java/org/apache/knox/gateway/util/GroupUtilsTest.java
new file mode 100644
index 000000000..6314884f6
--- /dev/null
+++ b/gateway-spi/src/test/java/org/apache/knox/gateway/util/GroupUtilsTest.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.knox.gateway.util;
+
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+
+public class GroupUtilsTest {
+
+ @Test
+ public void testSplitByLengthLimitOnly() {
+ final List<String> input = Arrays.asList("alpha", "beta", "gamma",
"delta", "epsilon");
+ final List<String> result = GroupUtils.getGroupStrings(input, 10, -1);
// size limit disabled
+
+ // Each chunk should respect the length limit (≤ 10 chars, including
commas)
+ assertTrue(result.stream().allMatch(s -> s.length() <= 10));
+ assertEquals(Arrays.asList("alpha,beta", "gamma", "delta", "epsilon"),
result);
+ }
+
+ @Test
+ public void testSplitBySizeLimitOnly() {
+ // We'll create UTF-8 multibyte strings to show that length != size
+ final String wideChar = "á"; // 1 char = 2 bytes in UTF-8
+ String group = String.join("", Collections.nCopies(2000, wideChar));
+ final List<String> input = Arrays.asList(group, group, group);
+
+ // 4KB = 4096 bytes; since each string 4000 bytes, only one fits per
chunk
+ final List<String> result = GroupUtils.getGroupStrings(input, 10,
4096);
+
+ // Should have 3 chunks because size limit applies, not length
+ assertEquals(3, result.size());
+
+ // Each chunk can exceed the length limit (since length = 2000 > 10)
+ assertTrue(result.stream().allMatch(s -> s.length() > 10));
+
+ // Each chunk must be ≤ 4KB in bytes
+ for (String s : result) {
+ int bytes = s.getBytes(StandardCharsets.UTF_8).length;
+ assertTrue("Chunk exceeds 4KB: " + bytes, bytes <= 4096);
+ }
+ }
+
+ @Test
+ public void testSplitBySizeLimitWithManySmallElements() throws Exception {
+ // 1. Create many small ASCII elements (5 bytes each + commas)
+ final List<String> input = new ArrayList<>();
+ for (int i = 1; i <= 100; i++) {
+ input.add("item" + i); // e.g., "item1", "item2", ...
+ }
+
+ final int sizeLimitBytes = 512;
+ final List<String> result = GroupUtils.getGroupStrings(input, 1000,
sizeLimitBytes);
+
+ assertEquals("Should be chunked into multiple groups", 2,
result.size());
+ assertEquals(508,
result.get(0).getBytes(StandardCharsets.UTF_8).length);
+ assertEquals(182,
result.get(1).getBytes(StandardCharsets.UTF_8).length);
+
+ // Verify all data preserved and ordered
+ final String joined = String.join(",", result);
+ final List<String> reconstructed = Arrays.asList(joined.split(","));
+ assertEquals("Item count should remain the same", input.size(),
reconstructed.size());
+ assertEquals("All items should appear in the same order", input,
reconstructed);
+ }
+
+ @Test
+ public void testSizeLimitTakesPrecedenceOverLengthLimit() {
+ // Use strings that easily exceed 10 chars but stay within 4KB
+ final List<String> input = Arrays.asList("abcdefghijklmno",
"pqrstuvwxyz", "1234567890");
+
+ // lengthLimit=10 would normally split them all apart, but
sizeLimit=4096 means we should keep them together
+ final List<String> result = GroupUtils.getGroupStrings(input, 10,
4096);
+
+ // Expect a single chunk (because size limit is large enough)
+ assertEquals("Should not split by length when sizeLimit > 0", 1,
result.size());
+ }
+
+ @Test
+ public void testEmptyInputReturnsEmptyList() {
+ final List<String> result =
GroupUtils.getGroupStrings(Collections.emptyList(), 1000, 4096);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void testShouldNotReturnEmptyElements() {
+ final List<String> input = Arrays.asList("longGroupName1",
"longGroupName2", "longGroupName3");
+ final List<String> result = GroupUtils.getGroupStrings(input, 10, 1);
// note the size limit is set to 1
+ assertEquals("Should not return a list with empty elements", 3,
result.size());
+ }
+
+ @Test
+ public void testConcurrentGroupStringCalls() throws Exception {
+ final int THREAD_COUNT = 20;
+ final int ITERATIONS = 1000;
+ final ExecutorService executor =
Executors.newFixedThreadPool(THREAD_COUNT);
+ final List<Future<List<String>>> futures = new ArrayList<>();
+
+ final List<String> input = new ArrayList<>();
+ for (int i = 0; i < 100; i++) {
+ input.add("group_" + i);
+ }
+
+ for (int t = 0; t < THREAD_COUNT; t++) {
+ futures.add(executor.submit(() -> {
+ for (int i = 0; i < ITERATIONS; i++) {
+ List<String> result = GroupUtils.getGroupStrings(input,
50, 512);
+ assertNotNull(result);
+ assertFalse(result.isEmpty());
+
+ Set<String> allGroups = new HashSet<>();
+ for (String chunk : result) {
+ int bytes =
chunk.getBytes(StandardCharsets.UTF_8).length;
+ assertTrue("Chunk exceeds 512 bytes: " + bytes, bytes
<= 512);
+
+ for (String g : chunk.split(",")) {
+ assertTrue("Duplicate group found: " + g,
allGroups.add(g));
+ }
+ }
+
+ assertEquals("Missing or extra groups in result",
input.size(), allGroups.size());
+ assertTrue("Not all expected groups are present",
allGroups.containsAll(input));
+ }
+ return null;
+ }));
+ }
+
+ for (Future<List<String>> f : futures) {
+ f.get(10, TimeUnit.SECONDS);
+ }
+
+ executor.shutdown();
+ assertTrue(executor.awaitTermination(3, TimeUnit.SECONDS));
+ }
+
+}