ConnectionHelloAuthTest.java

package redis.clients.jedis;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import redis.clients.jedis.exceptions.JedisAccessControlException;
import redis.clients.jedis.exceptions.JedisDataException;
import redis.clients.jedis.exceptions.JedisProtocolNotSupportedException;

/**
 * Unit tests for the {@code helloAndAuth} behaviour inside {@link Connection}.
 * <p>
 * These tests simulate different Redis server responses to the HELLO command by providing a fake
 * socket that returns pre-built RESP protocol bytes.
 * </p>
 */
public class ConnectionHelloAuthTest {

  // ---- RESP response constants ----

  private static final byte[] OK_REPLY = "+OK\r\n".getBytes();

  private static final byte[] AUTH_OK_REPLY = OK_REPLY;

  private static final byte[] NOAUTH_ERR = "-NOAUTH Authentication required.\r\n".getBytes();

  private static final byte[] UNKNOWN_CMD_ERR = "-ERR unknown command 'HELLO'\r\n".getBytes();

  /** RESP error for NOPROTO (Redis Enterprise / Cloud when protocol is disabled). */
  private static final byte[] NOPROTO_ERR = "-NOPROTO unsupported protocol version\r\n".getBytes();

  /** RESP error for NOPERM (ACL: user not allowed to run the command). */
  private static final byte[] NOPERM_ERR = "-NOPERM this user has no permissions to run the 'hello' command or its subcommand\r\n"
      .getBytes();

  /** RESP3 map with proto=3: %3\r\n+server\r\n+redis\r\n+version\r\n+7.0.0\r\n+proto\r\n:3\r\n */
  private static final byte[] HELLO_OK_MAP_PROTO3 = "%3\r\n+server\r\n+redis\r\n+version\r\n+7.0.0\r\n+proto\r\n:3\r\n"
      .getBytes();

  /** RESP3 map with proto=2: %3\r\n+server\r\n+redis\r\n+version\r\n+7.0.0\r\n+proto\r\n:2\r\n */
  private static final byte[] HELLO_OK_MAP_PROTO2 = "%3\r\n+server\r\n+redis\r\n+version\r\n+7.0.0\r\n+proto\r\n:2\r\n"
      .getBytes();

  // ---- helpers ----

  private static byte[] concat(byte[]... arrays) {
    int len = 0;
    for (byte[] a : arrays)
      len += a.length;
    byte[] result = new byte[len];
    int pos = 0;
    for (byte[] a : arrays) {
      System.arraycopy(a, 0, result, pos, a.length);
      pos += a.length;
    }
    return result;
  }

  private static JedisSocketFactory fakeSocketFactory(byte[] respBytes) {
    return () -> new FakeSocket(respBytes);
  }

  private static class FakeSocket extends Socket {
    private final InputStream in;
    private final OutputStream out = new ByteArrayOutputStream();

    FakeSocket(byte[] input) {
      this.in = new ByteArrayInputStream(input);
    }

    @Override
    public InputStream getInputStream() {
      return in;
    }

    @Override
    public OutputStream getOutputStream() {
      return out;
    }

    @Override
    public boolean isConnected() {
      return true;
    }

    @Override
    public boolean isClosed() {
      return false;
    }

    @Override
    public boolean isBound() {
      return true;
    }

    @Override
    public boolean isInputShutdown() {
      return false;
    }

    @Override
    public boolean isOutputShutdown() {
      return false;
    }

    @Override
    public int getSoTimeout() {
      return 0;
    }

    @Override
    public void setSoTimeout(int t) {
    }

    @Override
    public void close() {
    }
  }

  private static JedisClientConfig noAuthConfig(RedisProtocol proto) {
    // Disable auto-negotiation so the legacy "no HELLO" path is exercised when proto is null.
    return DefaultJedisClientConfig.builder().protocol(proto).autoNegotiateProtocol(false)
        .clientSetInfoConfig(ClientSetInfoConfig.DISABLED).build();
  }

  private static JedisClientConfig authConfig(RedisProtocol proto) {
    return DefaultJedisClientConfig.builder().protocol(proto).autoNegotiateProtocol(false)
        .user("default").password("secret").clientSetInfoConfig(ClientSetInfoConfig.DISABLED)
        .build();
  }

  // ---------------------------------------------------------------------------
  // requested = null (legacy mode: no HELLO sent, server default assumed RESP2)
  // ---------------------------------------------------------------------------

  @Nested
  @DisplayName("requested = null")
  class NullProtocolRequested {

    @Test
    @DisplayName("No HELLO sent when protocol is null ��� connection defaults to RESP2")
    void noHelloWhenProtocolNull() {
      try (Connection conn = new Connection(fakeSocketFactory(new byte[0]), noAuthConfig(null))) {
        // When no protocol is requested we fall back to the server default (assumed RESP2).
        assertEquals(RedisProtocol.RESP2, conn.getRedisProtocol());
        assertFalse(conn.isBroken());
      }
    }
  }

  // ---------------------------------------------------------------------------
  // requested = RESP2 (strict)
  // ---------------------------------------------------------------------------

  @Nested
  @DisplayName("requested = RESP2 (strict)")
  class Resp2StrictRequested {

    @Test
    @DisplayName("HELLO with proto=2 in response ��� RESP2 confirmed via protocol negotiation")
    void helloWithProto2InResponse() {
      try (Connection conn = new Connection(fakeSocketFactory(HELLO_OK_MAP_PROTO2),
          noAuthConfig(RedisProtocol.RESP2))) {
        assertEquals(RedisProtocol.RESP2, conn.getRedisProtocol());
        assertFalse(conn.isBroken());
      }
    }

    @Test
    @DisplayName("HELLO AUTH succeeds with RESP2 ��� protocol is negotiated normally")
    void helloWithAuthResp2Succeeds() {
      try (Connection conn = new Connection(fakeSocketFactory(concat(HELLO_OK_MAP_PROTO2)),
          authConfig(RedisProtocol.RESP2))) {
        assertEquals(RedisProtocol.RESP2, conn.getRedisProtocol());
        assertFalse(conn.isBroken());
      }
    }

    @Test
    @DisplayName("HELLO not supported with RESP2 ��� propagates error")
    void helloNotSupportedResp2PropagatesError() {
      assertThrows(JedisProtocolNotSupportedException.class,
        () -> new Connection(fakeSocketFactory(concat(UNKNOWN_CMD_ERR)),
            authConfig(RedisProtocol.RESP2)));
    }
  }

  // ---------------------------------------------------------------------------
  // requested = RESP3 (strict) ��� no fallback on negotiation failures
  // ---------------------------------------------------------------------------

  @Nested
  @DisplayName("requested = RESP3 (strict)")
  class Resp3StrictRequested {

    @Test
    @DisplayName("HELLO with proto=3 in response ��� RESP3 confirmed via protocol negotiation")
    void helloWithProto3InResponse() {
      try (Connection conn = new Connection(fakeSocketFactory(HELLO_OK_MAP_PROTO3),
          noAuthConfig(RedisProtocol.RESP3))) {
        assertEquals(RedisProtocol.RESP3, conn.getRedisProtocol());
        assertFalse(conn.isBroken());
      }
    }

    @Test
    @DisplayName("HELLO with AUTH succeeds with RESP3 ��� protocol is negotiated normally")
    void helloWithAuthResp3Succeeds() {
      try (Connection conn = new Connection(fakeSocketFactory(HELLO_OK_MAP_PROTO3),
          authConfig(RedisProtocol.RESP3))) {
        assertEquals(RedisProtocol.RESP3, conn.getRedisProtocol());
        assertFalse(conn.isBroken());
      }
    }

    @Test
    @DisplayName("HELLO AUTH with proto=3 in response ��� RESP3 confirmed")
    void helloAuthWithProto3InResponse() {
      try (Connection conn = new Connection(fakeSocketFactory(HELLO_OK_MAP_PROTO3),
          authConfig(RedisProtocol.RESP3))) {
        assertEquals(RedisProtocol.RESP3, conn.getRedisProtocol());
        assertFalse(conn.isBroken());
      }
    }

    @Test
    @DisplayName("HELLO rejected with NOAUTH ��� propagates auth error instead of silently downgrading")
    void helloRejectedNoAuthThrows() {
      // If the user asked for a specific protocol but didn't provide credentials,
      // the NOAUTH error must propagate so the caller knows auth is missing.
      // (HELLO -> NOAUTH) -> fallback to (authenticate, hello -> NOAUTH) -> propagate error
      assertThrows(JedisAccessControlException.class,
        () -> new Connection(fakeSocketFactory(concat(NOAUTH_ERR, NOAUTH_ERR)),
            noAuthConfig(RedisProtocol.RESP3)),
        "NOAUTH from HELLO should propagate as JedisAccessControlException");
    }

    @Test
    @DisplayName("HELLO rejected with NOPROTO ��� propagates error because protocol is explicitly disabled")
    void helloRejectedNoProtoThrows() {
      // Redis Enterprise / Cloud: the requested protocol version has been
      // explicitly disabled. Must propagate so the caller knows the
      // requested protocol is not available on this server.
      assertThrows(JedisProtocolNotSupportedException.class,
        () -> new Connection(fakeSocketFactory(NOPROTO_ERR), noAuthConfig(RedisProtocol.RESP3)),
        "NOPROTO from HELLO should propagate as JedisProtocolNotSupportedException");
    }

    @Test
    @DisplayName("HELLO rejected with unknown command (pre-6.0) ��� propagates as JedisProtocolNotSupportedException")
    void helloRejectedUnknownCommandThrows() {
      // Redis < 6.0 does not know the HELLO command. When the user explicitly
      // requested a protocol version, this must be reported as an error rather
      // than silently falling back.
      assertThrows(JedisProtocolNotSupportedException.class,
        () -> new Connection(fakeSocketFactory(UNKNOWN_CMD_ERR), noAuthConfig(RedisProtocol.RESP3)),
        "Unknown command HELLO on pre-6.0 should propagate as JedisProtocolNotSupportedException");
    }

    @Test
    @DisplayName("HELLO rejected with unexpected error ��� propagates as JedisDataException")
    void helloRejectedUnexpectedErrorThrows() {
      // An error that is neither unknown-command, NOPROTO, nor NOAUTH should
      // not be silently swallowed ��� it must propagate.
      byte[] unexpectedErr = "-ERR some unexpected server error\r\n".getBytes();
      assertThrows(JedisDataException.class,
        () -> new Connection(fakeSocketFactory(unexpectedErr), noAuthConfig(RedisProtocol.RESP3)),
        "Unexpected errors from HELLO should propagate as JedisDataException");
    }

    @Test
    @DisplayName("Explicit RESP3 with AUTH and NOPERM on HELLO ��� propagates JedisAccessControlException")
    void explicitResp3HelloNoPermPropagates() {
      // No fallback for explicit RESP3 ��� NOPERM (not NOAUTH) must propagate.
      assertThrows(JedisAccessControlException.class,
        () -> new Connection(fakeSocketFactory(NOPERM_ERR), authConfig(RedisProtocol.RESP3)),
        "NOPERM from HELLO with explicit RESP3 should propagate");
    }
  }

  // ---------------------------------------------------------------------------
  // requested = null + autoNegotiateProtocol=true ��� try HELLO 3 with RESP2 fallback
  // ---------------------------------------------------------------------------

  @Nested
  @DisplayName("requested = null + autoNegotiateProtocol=true")
  class AutoNegotiateRequested {

    @Test
    @DisplayName("Auto-negotiate with proto=3 in response ��� returns RESP3 from proto field")
    void autoNegotiateWithProto3InResponse() {
      try (Connection conn = new Connection(fakeSocketFactory(HELLO_OK_MAP_PROTO3),
          autoNegotiateNoAuthConfig())) {
        assertEquals(RedisProtocol.RESP3, conn.getRedisProtocol());
        assertFalse(conn.isBroken());
      }
    }

    @Test
    @DisplayName("Auto-negotiate with proto=2 in response ��� returns RESP2 from proto field")
    void autoNegotiateWithProto2InResponse() {
      try (Connection conn = new Connection(fakeSocketFactory(HELLO_OK_MAP_PROTO2),
          autoNegotiateNoAuthConfig())) {
        assertEquals(RedisProtocol.RESP2, conn.getRedisProtocol());
        assertFalse(conn.isBroken());
      }
    }

    @Test
    @DisplayName("Auto-negotiate with HELLO unknown command and no creds ��� falls back to RESP2")
    void autoNegotiateUnknownCommandResolvesToResp2() {
      // Pre-6.0 server: HELLO 3 -> unknown -> establishLegacyResp2 -> authenticate(null) (no-op)
      // -> HELLO 2 -> unknown -> infer RESP2.
      try (Connection conn = new Connection(
          fakeSocketFactory(concat(UNKNOWN_CMD_ERR, UNKNOWN_CMD_ERR)),
          autoNegotiateNoAuthConfig())) {
        assertEquals(RedisProtocol.RESP2, conn.getRedisProtocol());
        assertFalse(conn.isBroken());
      }
    }

    @Test
    @DisplayName("Auto-negotiate with AUTH and proto=3 in response ��� returns RESP3")
    void autoNegotiateAuthWithProto3() {
      try (Connection conn = new Connection(fakeSocketFactory(HELLO_OK_MAP_PROTO3),
          autoNegotiateAuthConfig())) {
        assertEquals(RedisProtocol.RESP3, conn.getRedisProtocol());
        assertFalse(conn.isBroken());
      }
    }

    @Test
    @DisplayName("Auto-negotiate with AUTH HELLO not supported ��� resolves to RESP2")
    void autoNegotiateWithAuthHelloNotSupportedResolvesToProto2() {
      // -> hello(3,user,pass) -> unknown command
      // -> (fallback to establishLegacyResp2)
      // -> auth -> ok ->
      // -> not require (hello(2)) -> unknown command
      try (Connection conn = new Connection(
          fakeSocketFactory(concat(UNKNOWN_CMD_ERR, AUTH_OK_REPLY, UNKNOWN_CMD_ERR)),
          autoNegotiateAuthConfig())) {
        assertEquals(RedisProtocol.RESP2, conn.getRedisProtocol());
        assertFalse(conn.isBroken());
      }
    }

    @Test
    @DisplayName("Auto-negotiate with AUTH and NOPROTO on HELLO 3 ��� falls back to RESP2 via HELLO 2")
    void autoNegotiateAuthHelloNoProtoFallsBackToResp2() {
      // -> hello(3,user,pass) -> NOPROTO
      // -> (fallback to establishLegacyResp2)
      // -> auth -> ok
      // -> hello(2) -> ok with proto=2
      try (Connection conn = new Connection(
          fakeSocketFactory(concat(NOPROTO_ERR, AUTH_OK_REPLY, HELLO_OK_MAP_PROTO2)),
          autoNegotiateAuthConfig())) {
        assertEquals(RedisProtocol.RESP2, conn.getRedisProtocol());
        assertFalse(conn.isBroken());
      }
    }
  }

  private static JedisClientConfig autoNegotiateNoAuthConfig() {
    return DefaultJedisClientConfig.builder().protocol(null).autoNegotiateProtocol(true)
        .clientSetInfoConfig(ClientSetInfoConfig.DISABLED).build();
  }

  private static JedisClientConfig autoNegotiateAuthConfig() {
    return DefaultJedisClientConfig.builder().protocol(null).autoNegotiateProtocol(true)
        .user("default").password("secret").clientSetInfoConfig(ClientSetInfoConfig.DISABLED)
        .build();
  }
}