IdAClientAsynchronousIT.java

/*
 * Copyright © 2023 Mark Raynsford <code@io7m.com> https://www.io7m.com
 *
 * Permission to use, copy, modify, and/or distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
 * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
 * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

package com.io7m.idstore.tests.integration;

import com.io7m.ervilla.api.EContainerSupervisorType;
import com.io7m.ervilla.test_extension.ErvillaCloseAfterAll;
import com.io7m.ervilla.test_extension.ErvillaConfiguration;
import com.io7m.ervilla.test_extension.ErvillaExtension;
import com.io7m.idstore.admin_client.IdAClients;
import com.io7m.idstore.admin_client.api.IdAClientAsynchronousType;
import com.io7m.idstore.admin_client.api.IdAClientConfiguration;
import com.io7m.idstore.admin_client.api.IdAClientCredentials;
import com.io7m.idstore.admin_client.api.IdAClientException;
import com.io7m.idstore.error_codes.IdErrorCode;
import com.io7m.idstore.model.IdAdmin;
import com.io7m.idstore.model.IdAdminColumn;
import com.io7m.idstore.model.IdAdminColumnOrdering;
import com.io7m.idstore.model.IdAdminPermissionSet;
import com.io7m.idstore.model.IdAdminSearchByEmailParameters;
import com.io7m.idstore.model.IdEmail;
import com.io7m.idstore.model.IdName;
import com.io7m.idstore.model.IdNonEmptyList;
import com.io7m.idstore.model.IdPasswordAlgorithmRedacted;
import com.io7m.idstore.model.IdPasswordException;
import com.io7m.idstore.model.IdRealName;
import com.io7m.idstore.model.IdTimeRange;
import com.io7m.idstore.protocol.admin.IdACommandAdminSearchByEmailBegin;
import com.io7m.idstore.protocol.admin.IdACommandAdminSelf;
import com.io7m.idstore.protocol.admin.IdACommandType;
import com.io7m.idstore.protocol.admin.IdAMessageType;
import com.io7m.idstore.protocol.admin.IdAResponseAdminSelf;
import com.io7m.idstore.protocol.admin.IdAResponseBlame;
import com.io7m.idstore.protocol.admin.IdAResponseError;
import com.io7m.idstore.protocol.admin.IdAResponseLogin;
import com.io7m.idstore.protocol.admin.IdAResponseUserBanDelete;
import com.io7m.idstore.protocol.admin.cb.IdACB1Messages;
import com.io7m.idstore.tests.extensions.IdTestDatabases;
import com.io7m.idstore.tests.extensions.IdTestServers;
import com.io7m.quixote.core.QWebServerType;
import com.io7m.quixote.core.QWebServers;
import com.io7m.verdant.core.VProtocolException;
import com.io7m.verdant.core.VProtocolSupported;
import com.io7m.verdant.core.VProtocols;
import com.io7m.verdant.core.cb.VProtocolMessages;
import com.io7m.zelador.test_extension.CloseableResourcesType;
import com.io7m.zelador.test_extension.ZeladorExtension;
import net.jqwik.api.Arbitraries;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestFactory;
import org.junit.jupiter.api.extension.ExtendWith;

import java.time.OffsetDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.stream.Stream;

import static com.io7m.idstore.error_codes.IdStandardErrorCodes.ADMIN_DUPLICATE_ID_NAME;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.ADMIN_NONEXISTENT;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.API_MISUSE_ERROR;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.AUTHENTICATION_ERROR;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.EMAIL_DUPLICATE;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.PROTOCOL_ERROR;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.USER_DUPLICATE_ID_NAME;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.USER_NONEXISTENT;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@Tag("integration")
@Tag("admin-client")
@ExtendWith({ErvillaExtension.class, ZeladorExtension.class})
@ErvillaConfiguration(disabledIfUnsupported = true)
public final class IdAClientAsynchronousIT
{
  private static final Set<IdErrorCode> ALLOWED_SMOKE_CODES =
    Set.of(
      ADMIN_DUPLICATE_ID_NAME,
      ADMIN_NONEXISTENT,
      USER_NONEXISTENT,
      USER_DUPLICATE_ID_NAME,
      EMAIL_DUPLICATE,
      API_MISUSE_ERROR
    );

  private static final IdACB1Messages MESSAGES = new IdACB1Messages();
  private static final IdAdmin ADMIN;
  private static final IdAClients CLIENTS = new IdAClients();

  private static final VProtocolSupported V1 =
    new VProtocolSupported(
      IdACB1Messages.protocolId(),
      1L,
      0L,
      "/v1/"
    );

  private static final VProtocols V_PROTOCOLS =
    new VProtocols(List.of(V1));
  private static final VProtocolMessages V_PROTOCOL_MESSAGES =
    VProtocolMessages.create();
  private static final byte[] VERSION_HEADER;

  static {
    try {
      VERSION_HEADER = V_PROTOCOL_MESSAGES.serialize(V_PROTOCOLS, 1);
    } catch (final VProtocolException e) {
      throw new IllegalStateException(e);
    }

    try {
      ADMIN = new IdAdmin(
        UUID.randomUUID(),
        new IdName("someone"),
        new IdRealName("Someone"),
        IdNonEmptyList.single(new IdEmail("someone@example.com")),
        OffsetDateTime.now(),
        OffsetDateTime.now(),
        IdPasswordAlgorithmRedacted.create().createHashed("x"),
        IdAdminPermissionSet.empty()
      );
    } catch (final IdPasswordException e) {
      throw new IllegalStateException(e);
    }
  }

  private static IdTestDatabases.IdDatabaseFixture DATABASE_FIXTURE;
  private IdAClientAsynchronousType client;
  private QWebServerType webServer;
  private IdTestServers.IdTestServerFixture serverFixture;

  @BeforeAll
  public static void setupOnce(
    final @ErvillaCloseAfterAll EContainerSupervisorType containers)
    throws Exception
  {
    DATABASE_FIXTURE = IdTestDatabases.create(containers, 15432);
  }

  @BeforeEach
  public void setupDatabase()
    throws Exception
  {
    DATABASE_FIXTURE.reset();
  }

  @BeforeEach
  public void setupServer(
    final CloseableResourcesType closeables)
    throws Exception
  {
    this.serverFixture =
      closeables.addPerTestResource(
        IdTestServers.create(
          DATABASE_FIXTURE,
          10025,
          50000,
          50001,
          51000
        ));

    this.client =
      closeables.addPerTestResource(
        CLIENTS.openAsynchronousClient(new IdAClientConfiguration(Locale.ROOT))
      );

    this.webServer =
      closeables.addPerTestResource(QWebServers.createServer(60001));
  }

  /**
   * Command retries work when the server indicates a session has expired.
   *
   * @throws Exception On errors
   */

  @Test
  public void testCommandRetry()
    throws Exception
  {
    this.webServer.addResponse()
      .forPath("/")
      .withStatus(200)
      .withContentType("application/verdant+cedarbridge")
      .withFixedData(VERSION_HEADER);

    this.webServer.addResponse()
      .forPath("/v1/login")
      .withStatus(200)
      .withContentType(IdACB1Messages.contentType())
      .withFixedData(MESSAGES.serialize(
        new IdAResponseLogin(UUID.randomUUID(), ADMIN)));

    this.webServer.addResponse()
      .forPath("/v1/command")
      .withStatus(401)
      .withContentType(IdACB1Messages.contentType())
      .withFixedData(
        MESSAGES.serialize(
          new IdAResponseError(
            UUID.randomUUID(),
            "error",
            AUTHENTICATION_ERROR,
            Map.of(),
            Optional.empty(),
            IdAResponseBlame.BLAME_CLIENT
          ))
      );

    this.webServer.addResponse()
      .forPath("/v1/login")
      .withStatus(200)
      .withContentType(IdACB1Messages.contentType())
      .withFixedData(MESSAGES.serialize(
        new IdAResponseLogin(UUID.randomUUID(), ADMIN))
      );

    this.webServer.addResponse()
      .forPath("/v1/command")
      .withStatus(200)
      .withContentType(IdACB1Messages.contentType())
      .withFixedData(
        MESSAGES.serialize(new IdAResponseAdminSelf(UUID.randomUUID(), ADMIN))
      );

    this.client.loginAsyncOrElseThrow(
      new IdAClientCredentials(
        "someone",
        "whatever",
        this.webServer.uri(),
        Map.of()
      ),
      IdAClientException::ofError
    ).get();

    final var result = (IdAResponseAdminSelf)
      this.client.executeAsyncOrElseThrow(
          new IdACommandAdminSelf(),
          IdAClientException::ofError)
        .get();
    assertEquals(ADMIN.id(), result.admin().id());
  }

  /**
   * The client fails if the server returns a non-response.
   *
   * @throws Exception On errors
   */

  @Test
  public void testServerReturnsNonResponse()
    throws Exception
  {
    this.webServer.addResponse()
      .forPath("/")
      .withStatus(200)
      .withContentType("application/verdant+cedarbridge")
      .withFixedData(VERSION_HEADER);

    this.webServer.addResponse()
      .forPath("/v1/login")
      .withStatus(200)
      .withContentType(IdACB1Messages.contentType())
      .withFixedData(MESSAGES.serialize(
        new IdAResponseLogin(UUID.randomUUID(), ADMIN))
      );

    this.webServer.addResponse()
      .forPath("/v1/command")
      .withStatus(200)
      .withContentType(IdACB1Messages.contentType())
      .withFixedData(MESSAGES.serialize(new IdACommandAdminSelf()));

    this.client.loginAsyncOrElseThrow(
      new IdAClientCredentials(
        "someone",
        "whatever",
        this.webServer.uri(),
        Map.of()
      ),
      IdAClientException::ofError
    ).get();

    final var ex =
      assertThrows(
        IdAClientException.class,
        () -> {
          try {
            this.client.executeAsyncOrElseThrow(
              new IdACommandAdminSelf(),
              IdAClientException::ofError
            ).get();
          } catch (final ExecutionException e) {
            throw e.getCause();
          }
        }
      );

    assertEquals(PROTOCOL_ERROR, ex.errorCode());
  }

  /**
   * The client fails if the server returns the wrong response.
   *
   * @throws Exception On errors
   */

  @Test
  public void testServerReturnsWrongResponse()
    throws Exception
  {
    this.webServer.addResponse()
      .forPath("/")
      .withStatus(200)
      .withContentType("application/verdant+cedarbridge")
      .withFixedData(VERSION_HEADER);

    this.webServer.addResponse()
      .forPath("/v1/login")
      .withStatus(200)
      .withContentType(IdACB1Messages.contentType())
      .withFixedData(MESSAGES.serialize(
        new IdAResponseLogin(UUID.randomUUID(), ADMIN)));

    this.webServer.addResponse()
      .forPath("/v1/command")
      .withStatus(200)
      .withContentType(IdACB1Messages.contentType())
      .withFixedData(MESSAGES.serialize(
        new IdAResponseUserBanDelete(UUID.randomUUID()))
      );

    this.client.loginAsyncOrElseThrow(
      new IdAClientCredentials(
        "someone",
        "whatever",
        this.webServer.uri(),
        Map.of()
      ),
      IdAClientException::ofError
    ).get();

    final var ex =
      assertThrows(
        IdAClientException.class,
        () -> {
          try {
            this.client.executeAsyncOrElseThrow(
                new IdACommandAdminSelf(),
                IdAClientException::ofError)
              .get();
          } catch (final ExecutionException e) {
            throw e.getCause();
          }
        }
      );

    assertEquals(PROTOCOL_ERROR, ex.errorCode());
  }

  /**
   * Logging in and seeing oneself works.
   *
   * @throws Exception On errors
   */

  @Test
  public void testLoginSelf()
    throws Exception
  {
    final var admin =
      this.serverFixture.createAdminInitial("admin", "12345678");

    this.client.loginAsyncOrElseThrow(
      new IdAClientCredentials(
        "admin",
        "12345678",
        this.serverFixture.server().adminAPI(),
        Map.of()
      ),
      IdAClientException::ofError
    ).get();

    final var self =
      (IdAResponseAdminSelf)
        this.client.executeAsyncOrElseThrow(
          new IdACommandAdminSelf(),
          IdAClientException::ofError
        ).get();
    assertEquals(admin, self.admin().id());

    this.client.close();

    assertThrows(IllegalStateException.class, () -> {
      this.client.loginAsyncOrElseThrow(
        new IdAClientCredentials(
          "admin",
          "12345678",
          this.serverFixture.server().adminAPI(),
          Map.of()
        ),
        IdAClientException::ofError
      ).get();
    });
  }

  /**
   * Logging in with the wrong password fails.
   *
   * @throws Exception On errors
   */

  @Test
  public void testLoginWrongPassword()
    throws Exception
  {
    final var admin =
      this.serverFixture.createAdminInitial("admin", "12345678");

    final var ex =
      assertThrows(IdAClientException.class, () -> {
        try {
          this.client.loginAsyncOrElseThrow(
            new IdAClientCredentials(
              "admin",
              "1234",
              this.serverFixture.server().adminAPI(),
              Map.of()
            ),
            IdAClientException::ofError
          ).get();
        } catch (final ExecutionException e) {
          throw e.getCause();
        }
      });

    assertEquals(AUTHENTICATION_ERROR, ex.errorCode());
  }

  /**
   * Executing a command without being connected results in an error.
   *
   * @return The tests
   */

  @TestFactory
  public Stream<DynamicTest> testDisconnected()
  {
    return Arbitraries.defaultFor(IdAMessageType.class)
      .sampleStream()
      .filter(m -> m instanceof IdACommandType<?>)
      .map(IdACommandType.class::cast)
      .limit(1000L)
      .map(c -> {
        return DynamicTest.dynamicTest(
          "testDisconnected_%s".formatted(c),
          () -> {
            assertThrows(IdAClientException.class, () -> {
              try {
                this.client.executeAsyncOrElseThrow(
                    c,
                    IdAClientException::ofError)
                  .get();
              } catch (final ExecutionException e) {
                throw e.getCause();
              }
            });
          }
        );
      });
  }

  /**
   * A smoke test that simply executes random commands.
   */

  @Test
  public void testSmoke()
    throws Exception
  {
    final var admin =
      this.serverFixture.createAdminInitial("admin", "12345678");

    final var messages =
      Arbitraries.defaultFor(IdAMessageType.class)
        .sampleStream()
        .filter(m -> m instanceof IdACommandType<?>)
        .map(IdACommandType.class::cast)
        .limit(2000L)
        .toList();

    this.client.loginAsyncOrElseThrow(
      new IdAClientCredentials(
        "admin",
        "12345678",
        this.serverFixture.server().adminAPI(),
        Map.of()
      ),
      IdAClientException::ofError
    ).get();

    for (final var c : messages) {
      try {
        this.client.executeAsyncOrElseThrow(c, IdAClientException::ofError)
          .get();
      } catch (final ExecutionException ex) {
        final IdAClientException cause = (IdAClientException) ex.getCause();
        if (ALLOWED_SMOKE_CODES.contains(cause.errorCode())) {
          continue;
        }
        throw ex;
      }
    }
  }

  /**
   * Emails with bad encodings do not cause problems.
   *
   * @throws Exception On errors
   */

  @Test
  public void testEmailEncodingBug()
    throws Exception
  {
    final var admin =
      this.serverFixture.createAdminInitial("admin", "12345678");

    this.client.loginAsyncOrElseThrow(
      new IdAClientCredentials(
        "admin",
        "12345678",
        this.serverFixture.server().adminAPI(),
        Map.of()
      ),
      IdAClientException::ofError
    ).get();

    final var ex =
      assertThrows(IdAClientException.class, () -> {
        try {
          this.client.executeAsyncOrElseThrow(
            new IdACommandAdminSearchByEmailBegin(
              new IdAdminSearchByEmailParameters(
                IdTimeRange.largest(),
                IdTimeRange.largest(),
                "\0",
                new IdAdminColumnOrdering(IdAdminColumn.BY_IDNAME, true),
                100
              )
            ),
            IdAClientException::ofError
          ).get();
        } catch (final ExecutionException e) {
          throw e.getCause();
        }
      });

    assertTrue(ex.getMessage().contains("invalid byte sequence for encoding"));
    assertEquals(PROTOCOL_ERROR, ex.errorCode());
  }
}