This is an automated email from the ASF dual-hosted git repository.

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 771c5e22cd [#11966] feat(auth): add X-Gravitino-Active-Roles header 
grammar and parser (#11970)
771c5e22cd is described below

commit 771c5e22cd69bb68097a96b454fb764c938e99a8
Author: Bharath Krishna <[email protected]>
AuthorDate: Tue Jul 14 23:35:50 2026 -0700

    [#11966] feat(auth): add X-Gravitino-Active-Roles header grammar and parser 
(#11970)
    
    ### What changes were proposed in this pull request?
    
    First step of role assumption (SET ROLE): the header grammar and parser
    only, with no wiring and no behavior change yet.
    
    - Add the `X-Gravitino-Active-Roles` header constant to `AuthConstants`.
    - Add `ActiveRoles`, an immutable value type modeling the declared
    active roles as `ALL` / `NONE` / `NAMED` (ordered, de-duplicated role
    names).
    - Add `ActiveRolesParser`, which parses a raw header value into
    `ActiveRoles`:
      - absent / blank value maps to `ALL` (today's behavior);
    - entries are trimmed and de-duplicated; role names are case-sensitive;
    - `ALL` and `NONE` are reserved keywords (exact upper-case) and must
    each appear alone;
    - a malformed value (empty entry, or a reserved keyword combined with
    anything) raises `IllegalActiveRolesException` (extends
    `IllegalArgumentException`).
    - Unit tests covering the full grammar, malformed inputs, and the value
    type.
    
    This is intentionally scoped to the model plus parser so it can be
    reviewed on its own. Carrying the value on the request, the `400`/`403`
    mapping, and enforcement follow in later PRs.
    
    Part of #11966.
    
    ### Why are the changes needed?
    
    Implements Phase 1 of the approved design
    
[design-docs/gravitino-role-assumption.md](https://github.com/apache/gravitino/blob/main/design-docs/gravitino-role-assumption.md)
    (discussion #10894). A caller needs a way to declare which of its roles
    should be active for a request so authorization can be narrowed; this PR
    establishes the header grammar that the rest of the feature builds on.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Not yet. It adds the `X-Gravitino-Active-Roles` header name and its
    grammar as internal building blocks, but nothing reads the header or
    changes authorization behavior in this PR.
    
    ### How was this patch tested?
    
    New unit tests (JUnit 5): `TestActiveRolesParser` covers single role,
    comma-separated lists, whitespace trimming, duplicate collapsing,
    case-sensitivity, `ALL`/`NONE`, absent/blank values, and malformed
    inputs; `TestActiveRoles` covers the value type contract. Verified
    locally:
    
    - `./gradlew :common:test --tests
    "org.apache.gravitino.auth.TestActiveRolesParser" --tests
    "org.apache.gravitino.auth.TestActiveRoles"`
    - `./gradlew :common:spotlessCheck :common:javadoc`
---
 .../org/apache/gravitino/auth/ActiveRoles.java     | 164 +++++++++++++++++++++
 .../apache/gravitino/auth/ActiveRolesParser.java   | 122 +++++++++++++++
 .../org/apache/gravitino/auth/AuthConstants.java   |  10 ++
 .../auth/IllegalActiveRolesException.java          |  41 ++++++
 .../org/apache/gravitino/auth/TestActiveRoles.java | 102 +++++++++++++
 .../gravitino/auth/TestActiveRolesParser.java      | 122 +++++++++++++++
 6 files changed, 561 insertions(+)

diff --git a/common/src/main/java/org/apache/gravitino/auth/ActiveRoles.java 
b/common/src/main/java/org/apache/gravitino/auth/ActiveRoles.java
new file mode 100644
index 0000000000..261aaa7a3c
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/auth/ActiveRoles.java
@@ -0,0 +1,164 @@
+/*
+ * 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.gravitino.auth;
+
+import com.google.common.base.Preconditions;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * The set of roles a caller has declared active for a request (role 
assumption).
+ *
+ * <p>An instance is one of three kinds:
+ *
+ * <ul>
+ *   <li>{@link Mode#ALL}: every role the caller holds is active, the same as 
an absent header;
+ *   <li>{@link Mode#NONE}: no role is active;
+ *   <li>{@link Mode#NAMED}: only the listed role names are active.
+ * </ul>
+ *
+ * <p>This type only models the declared value. Checking that the caller 
actually holds the named
+ * roles happens later on the server.
+ */
+public final class ActiveRoles {
+
+  /** The kind of active-role declaration. */
+  public enum Mode {
+    /** Every role the caller holds is active. */
+    ALL,
+    /** No role is active. */
+    NONE,
+    /** Only the explicitly named roles are active. */
+    NAMED
+  }
+
+  private static final ActiveRoles ALL = new ActiveRoles(Mode.ALL, 
Collections.emptySet());
+  private static final ActiveRoles NONE = new ActiveRoles(Mode.NONE, 
Collections.emptySet());
+
+  private final Mode mode;
+  private final Set<String> roleNames;
+
+  private ActiveRoles(Mode mode, Set<String> roleNames) {
+    this.mode = mode;
+    this.roleNames = roleNames;
+  }
+
+  /**
+   * Returns the {@link Mode#ALL} declaration: every role the caller holds is 
active.
+   *
+   * @return the {@code ALL} declaration
+   */
+  public static ActiveRoles all() {
+    return ALL;
+  }
+
+  /**
+   * Returns the {@link Mode#NONE} declaration: no role is active.
+   *
+   * @return the {@code NONE} declaration
+   */
+  public static ActiveRoles none() {
+    return NONE;
+  }
+
+  /**
+   * Returns a {@link Mode#NAMED} declaration for the given role names. Names 
are de-duplicated
+   * while preserving their first-seen order; role names are treated 
case-sensitively.
+   *
+   * @param roleNames the active role names; must be non-empty and contain no 
blank name
+   * @return a {@code NAMED} declaration
+   * @throws IllegalArgumentException if {@code roleNames} is null, empty, or 
contains a blank name
+   */
+  public static ActiveRoles of(Collection<String> roleNames) {
+    Preconditions.checkArgument(
+        roleNames != null && !roleNames.isEmpty(), "Active role names must not 
be empty");
+    Set<String> names = new LinkedHashSet<>();
+    for (String name : roleNames) {
+      Preconditions.checkArgument(
+          name != null && !name.trim().isEmpty(), "Active role name must not 
be blank");
+      names.add(name);
+    }
+    return new ActiveRoles(Mode.NAMED, Collections.unmodifiableSet(names));
+  }
+
+  /**
+   * Returns the kind of this declaration.
+   *
+   * @return the mode
+   */
+  public Mode mode() {
+    return mode;
+  }
+
+  /**
+   * Returns the declared role names. Empty unless {@link #mode()} is {@link 
Mode#NAMED}.
+   *
+   * @return an immutable, ordered set of role names
+   */
+  public Set<String> roleNames() {
+    return roleNames;
+  }
+
+  /**
+   * Whether this declaration activates every role the caller holds.
+   *
+   * @return {@code true} if the mode is {@link Mode#ALL}
+   */
+  public boolean isAll() {
+    return mode == Mode.ALL;
+  }
+
+  /**
+   * Whether this declaration activates no role.
+   *
+   * @return {@code true} if the mode is {@link Mode#NONE}
+   */
+  public boolean isNone() {
+    return mode == Mode.NONE;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof ActiveRoles)) {
+      return false;
+    }
+    ActiveRoles that = (ActiveRoles) o;
+    return mode == that.mode && roleNames.equals(that.roleNames);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(mode, roleNames);
+  }
+
+  @Override
+  public String toString() {
+    if (mode == Mode.NAMED) {
+      return "ActiveRoles{NAMED=" + roleNames + "}";
+    }
+    return "ActiveRoles{" + mode + "}";
+  }
+}
diff --git 
a/common/src/main/java/org/apache/gravitino/auth/ActiveRolesParser.java 
b/common/src/main/java/org/apache/gravitino/auth/ActiveRolesParser.java
new file mode 100644
index 0000000000..8268321142
--- /dev/null
+++ b/common/src/main/java/org/apache/gravitino/auth/ActiveRolesParser.java
@@ -0,0 +1,122 @@
+/*
+ * 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.gravitino.auth;
+
+import java.util.LinkedHashSet;
+import java.util.Set;
+import javax.annotation.Nullable;
+
+/**
+ * Parses the {@link AuthConstants#X_GRAVITINO_ACTIVE_ROLES_HEADER} header 
value into an {@link
+ * ActiveRoles} declaration.
+ *
+ * <p>Accepted grammar:
+ *
+ * <ul>
+ *   <li>an absent, empty, or whitespace-only value maps to {@link 
ActiveRoles#all()};
+ *   <li>{@code ALL} maps to {@link ActiveRoles#all()};
+ *   <li>{@code NONE} maps to {@link ActiveRoles#none()};
+ *   <li>a role name or comma-separated list of role names maps to {@link 
ActiveRoles#of}.
+ * </ul>
+ *
+ * <p>Entries are trimmed and de-duplicated, and role names are 
case-sensitive. {@code ALL} and
+ * {@code NONE} are reserved keywords, matched exactly in upper case, and each 
must appear on its
+ * own, so a role literally named {@code ALL} or {@code NONE} cannot be 
activated by name.
+ *
+ * <p>This performs syntactic validation only. Checking that the caller 
actually holds a named role
+ * happens later on the server.
+ */
+public final class ActiveRolesParser {
+
+  /** The reserved keyword that activates every role the caller holds. */
+  public static final String ALL_KEYWORD = "ALL";
+
+  /** The reserved keyword that activates no role. */
+  public static final String NONE_KEYWORD = "NONE";
+
+  private ActiveRolesParser() {}
+
+  /**
+   * Parses a raw header value into an {@link ActiveRoles} declaration.
+   *
+   * @param rawValue the raw header value, or {@code null} when the header is 
absent
+   * @return the parsed declaration
+   * @throws IllegalActiveRolesException if the value is syntactically invalid 
(an empty entry, or a
+   *     reserved keyword combined with any other value)
+   */
+  public static ActiveRoles parse(@Nullable String rawValue) {
+    if (rawValue == null) {
+      return ActiveRoles.all();
+    }
+    String trimmed = rawValue.trim();
+    if (trimmed.isEmpty()) {
+      return ActiveRoles.all();
+    }
+
+    // Use a negative limit so trailing empty entries (e.g. "analyst,") are 
kept and rejected below
+    // instead of being silently dropped.
+    String[] parts = trimmed.split(",", -1);
+    Set<String> names = new LinkedHashSet<>();
+    boolean sawAll = false;
+    boolean sawNone = false;
+    for (String part : parts) {
+      String token = part.trim();
+      if (token.isEmpty()) {
+        throw new IllegalActiveRolesException(
+            "Invalid '"
+                + AuthConstants.X_GRAVITINO_ACTIVE_ROLES_HEADER
+                + "' header: empty role entry in value '"
+                + rawValue
+                + "'");
+      }
+      if (ALL_KEYWORD.equals(token)) {
+        sawAll = true;
+      } else if (NONE_KEYWORD.equals(token)) {
+        sawNone = true;
+      } else {
+        names.add(token);
+      }
+    }
+
+    // A reserved keyword must appear on its own. Every entry is non-empty 
here, so a reserved
+    // keyword is valid only when it is the single entry.
+    boolean sawReserved = sawAll || sawNone;
+    if (sawReserved && parts.length > 1) {
+      throw new IllegalActiveRolesException(
+          "Invalid '"
+              + AuthConstants.X_GRAVITINO_ACTIVE_ROLES_HEADER
+              + "' header: reserved keyword "
+              + ALL_KEYWORD
+              + "/"
+              + NONE_KEYWORD
+              + " must appear alone in value '"
+              + rawValue
+              + "'");
+    }
+
+    if (sawAll) {
+      return ActiveRoles.all();
+    }
+    if (sawNone) {
+      return ActiveRoles.none();
+    }
+    return ActiveRoles.of(names);
+  }
+}
diff --git a/common/src/main/java/org/apache/gravitino/auth/AuthConstants.java 
b/common/src/main/java/org/apache/gravitino/auth/AuthConstants.java
index 8750c4c942..ea79a5452e 100644
--- a/common/src/main/java/org/apache/gravitino/auth/AuthConstants.java
+++ b/common/src/main/java/org/apache/gravitino/auth/AuthConstants.java
@@ -42,6 +42,16 @@ public final class AuthConstants {
   /** The HTTP header used to pass the authentication token. */
   public static final String HTTP_CHALLENGE_HEADER = "WWW-Authenticate";
 
+  /**
+   * The HTTP header a caller uses to declare the active role(s) for a request 
(role assumption).
+   *
+   * <p>When present, authorization is narrowed so that only the declared 
roles are considered for
+   * allow decisions. The value is a single role name, a comma-separated list 
of role names, or one
+   * of the reserved keywords {@code ALL} / {@code NONE}. An absent header is 
equivalent to {@code
+   * ALL} (today's behavior). See {@link ActiveRolesParser} for the accepted 
grammar.
+   */
+  public static final String X_GRAVITINO_ACTIVE_ROLES_HEADER = 
"X-Gravitino-Active-Roles";
+
   /** The default username used for anonymous access. */
   public static final String ANONYMOUS_USER = "anonymous";
 
diff --git 
a/common/src/main/java/org/apache/gravitino/auth/IllegalActiveRolesException.java
 
b/common/src/main/java/org/apache/gravitino/auth/IllegalActiveRolesException.java
new file mode 100644
index 0000000000..b0c0096f6e
--- /dev/null
+++ 
b/common/src/main/java/org/apache/gravitino/auth/IllegalActiveRolesException.java
@@ -0,0 +1,41 @@
+/*
+ * 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.gravitino.auth;
+
+/**
+ * Thrown when the value of the {@link 
AuthConstants#X_GRAVITINO_ACTIVE_ROLES_HEADER} header is
+ * syntactically invalid.
+ *
+ * <p>This signals a malformed declaration (for example an empty entry, or a 
reserved keyword
+ * combined with another value) as opposed to a well-formed value that names a 
role the caller does
+ * not hold. It extends {@link IllegalArgumentException} so callers can 
surface it as a {@code 400
+ * Bad Request}.
+ */
+public class IllegalActiveRolesException extends IllegalArgumentException {
+
+  /**
+   * Constructs an {@code IllegalActiveRolesException} with the given message.
+   *
+   * @param message the detail message describing why the header value is 
invalid
+   */
+  public IllegalActiveRolesException(String message) {
+    super(message);
+  }
+}
diff --git 
a/common/src/test/java/org/apache/gravitino/auth/TestActiveRoles.java 
b/common/src/test/java/org/apache/gravitino/auth/TestActiveRoles.java
new file mode 100644
index 0000000000..e99fb8f759
--- /dev/null
+++ b/common/src/test/java/org/apache/gravitino/auth/TestActiveRoles.java
@@ -0,0 +1,102 @@
+/*
+ * 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.gravitino.auth;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import org.apache.gravitino.auth.ActiveRoles.Mode;
+import org.junit.jupiter.api.Test;
+
+public class TestActiveRoles {
+
+  @Test
+  public void testAll() {
+    ActiveRoles roles = ActiveRoles.all();
+    assertEquals(Mode.ALL, roles.mode());
+    assertTrue(roles.isAll());
+    assertFalse(roles.isNone());
+    assertTrue(roles.roleNames().isEmpty());
+    assertSame(ActiveRoles.all(), roles);
+  }
+
+  @Test
+  public void testNone() {
+    ActiveRoles roles = ActiveRoles.none();
+    assertEquals(Mode.NONE, roles.mode());
+    assertTrue(roles.isNone());
+    assertFalse(roles.isAll());
+    assertTrue(roles.roleNames().isEmpty());
+    assertSame(ActiveRoles.none(), roles);
+  }
+
+  @Test
+  public void testNamedPreservesOrderAndDeduplicates() {
+    ActiveRoles roles = ActiveRoles.of(Arrays.asList("reader", "analyst", 
"reader"));
+    assertEquals(Mode.NAMED, roles.mode());
+    assertFalse(roles.isAll());
+    assertFalse(roles.isNone());
+    assertIterableEquals(Arrays.asList("reader", "analyst"), 
roles.roleNames());
+  }
+
+  @Test
+  public void testOfNullThrows() {
+    assertThrows(IllegalArgumentException.class, () -> ActiveRoles.of(null));
+  }
+
+  @Test
+  public void testOfEmptyThrows() {
+    assertThrows(IllegalArgumentException.class, () -> 
ActiveRoles.of(Collections.emptyList()));
+  }
+
+  @Test
+  public void testOfBlankNameThrows() {
+    assertThrows(
+        IllegalArgumentException.class, () -> 
ActiveRoles.of(Arrays.asList("analyst", "  ")));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> ActiveRoles.of(Collections.singletonList((String) null)));
+  }
+
+  @Test
+  public void testRoleNamesIsImmutable() {
+    ActiveRoles roles = ActiveRoles.of(Collections.singletonList("analyst"));
+    assertThrows(UnsupportedOperationException.class, () -> 
roles.roleNames().add("reader"));
+  }
+
+  @Test
+  public void testEqualsAndHashCode() {
+    ActiveRoles a = ActiveRoles.of(Arrays.asList("analyst", "reader"));
+    ActiveRoles b = ActiveRoles.of(Arrays.asList("analyst", "reader"));
+    assertEquals(a, b);
+    assertEquals(a.hashCode(), b.hashCode());
+
+    assertNotEquals(a, ActiveRoles.of(Collections.singletonList("analyst")));
+    assertNotEquals(ActiveRoles.all(), ActiveRoles.none());
+    assertNotEquals(ActiveRoles.all(), 
ActiveRoles.of(Collections.singletonList("analyst")));
+  }
+}
diff --git 
a/common/src/test/java/org/apache/gravitino/auth/TestActiveRolesParser.java 
b/common/src/test/java/org/apache/gravitino/auth/TestActiveRolesParser.java
new file mode 100644
index 0000000000..6e1716966a
--- /dev/null
+++ b/common/src/test/java/org/apache/gravitino/auth/TestActiveRolesParser.java
@@ -0,0 +1,122 @@
+/*
+ * 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.gravitino.auth;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertIterableEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import org.apache.gravitino.auth.ActiveRoles.Mode;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+public class TestActiveRolesParser {
+
+  @ParameterizedTest
+  @NullSource
+  @ValueSource(strings = {"", "   ", "\t"})
+  public void testAbsentOrBlankIsAll(String value) {
+    ActiveRoles roles = ActiveRolesParser.parse(value);
+    assertEquals(Mode.ALL, roles.mode());
+    assertTrue(roles.isAll());
+    assertTrue(roles.roleNames().isEmpty());
+  }
+
+  @Test
+  public void testAllKeyword() {
+    assertEquals(Mode.ALL, ActiveRolesParser.parse("ALL").mode());
+  }
+
+  @Test
+  public void testNoneKeyword() {
+    ActiveRoles roles = ActiveRolesParser.parse("NONE");
+    assertEquals(Mode.NONE, roles.mode());
+    assertTrue(roles.isNone());
+    assertTrue(roles.roleNames().isEmpty());
+  }
+
+  @Test
+  public void testSingleRole() {
+    ActiveRoles roles = ActiveRolesParser.parse("analyst");
+    assertEquals(Mode.NAMED, roles.mode());
+    assertIterableEquals(Collections.singletonList("analyst"), 
roles.roleNames());
+  }
+
+  @Test
+  public void testCommaSeparatedList() {
+    ActiveRoles roles = ActiveRolesParser.parse("analyst,reader");
+    assertEquals(Mode.NAMED, roles.mode());
+    assertIterableEquals(Arrays.asList("analyst", "reader"), 
roles.roleNames());
+  }
+
+  @Test
+  public void testWhitespaceIsTrimmed() {
+    ActiveRoles roles = ActiveRolesParser.parse("  analyst ,  reader  ");
+    assertIterableEquals(Arrays.asList("analyst", "reader"), 
roles.roleNames());
+  }
+
+  @Test
+  public void testDuplicatesCollapseAndPreserveOrder() {
+    ActiveRoles roles = ActiveRolesParser.parse("reader, analyst, reader");
+    assertIterableEquals(Arrays.asList("reader", "analyst"), 
roles.roleNames());
+  }
+
+  @Test
+  public void testRoleNamesAreCaseSensitive() {
+    ActiveRoles roles = ActiveRolesParser.parse("Analyst,analyst");
+    assertIterableEquals(Arrays.asList("Analyst", "analyst"), 
roles.roleNames());
+  }
+
+  @Test
+  public void testKeywordsAreCaseSensitiveSoLowercaseIsARoleName() {
+    ActiveRoles roles = ActiveRolesParser.parse("all");
+    assertEquals(Mode.NAMED, roles.mode());
+    assertIterableEquals(Collections.singletonList("all"), roles.roleNames());
+  }
+
+  @ParameterizedTest
+  @ValueSource(
+      strings = {
+        "analyst,", // trailing comma
+        ",analyst", // leading comma
+        "analyst,,reader", // empty middle entry
+        ",", // only a comma
+        "ALL,analyst", // reserved keyword with a role name
+        "analyst,ALL", // reserved keyword with a role name
+        "NONE,reader", // reserved keyword with a role name
+        "ALL,NONE", // two different reserved keywords
+        "ALL,ALL", // repeated reserved keyword
+        "NONE,NONE" // repeated reserved keyword
+      })
+  public void testMalformedValuesThrow(String value) {
+    assertThrows(IllegalActiveRolesException.class, () -> 
ActiveRolesParser.parse(value));
+  }
+
+  @Test
+  public void testMalformedExceptionIsIllegalArgument() {
+    // The server maps this to 400 Bad Request, so it must remain an 
IllegalArgumentException.
+    assertThrows(IllegalArgumentException.class, () -> 
ActiveRolesParser.parse("analyst,"));
+  }
+}

Reply via email to