IdDatabasesTest.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.database;

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.database.api.IdDatabaseConfiguration;
import com.io7m.idstore.database.api.IdDatabaseCreate;
import com.io7m.idstore.database.api.IdDatabaseException;
import com.io7m.idstore.database.api.IdDatabaseTelemetry;
import com.io7m.idstore.database.api.IdDatabaseUpgrade;
import com.io7m.idstore.database.postgres.IdDatabases;
import com.io7m.idstore.model.IdVersion;
import com.io7m.idstore.strings.IdStrings;
import com.io7m.idstore.tests.extensions.IdTestDatabases;
import com.io7m.zelador.test_extension.ZeladorExtension;
import io.opentelemetry.api.OpenTelemetry;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.postgresql.ds.PGConnectionPoolDataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.Clock;
import java.util.Locale;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@ExtendWith({ErvillaExtension.class, ZeladorExtension.class})
@ErvillaConfiguration(disabledIfUnsupported = true)
public final class IdDatabasesTest
{
  private static final Logger LOG =
    LoggerFactory.getLogger(IdDatabasesTest.class);

  private static IdTestDatabases.IdDatabaseFixture DATABASE_FIXTURE;
  private IdDatabaseConfiguration databaseConfiguration;
  private IdDatabaseConfiguration databaseConfigurationWithoutUpgrades;
  private IdDatabases databases;
  private PGConnectionPoolDataSource dataSource;

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

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

    this.databaseConfiguration =
      DATABASE_FIXTURE.databaseConfiguration();

    this.databaseConfigurationWithoutUpgrades =
      new IdDatabaseConfiguration(
        "idstore_install",
        "12345678",
        "12345678",
        Optional.of("12345678"),
        "127.0.0.1",
        DATABASE_FIXTURE.databaseConfiguration().port(),
        "idstore",
        IdDatabaseCreate.CREATE_DATABASE,
        IdDatabaseUpgrade.DO_NOT_UPGRADE_DATABASE,
        IdStrings.create(Locale.ROOT),
        Clock.systemUTC()
      );

    final var url = new StringBuilder(128);
    url.append("jdbc:postgresql://");
    url.append(this.databaseConfiguration.address());
    url.append(":");
    url.append(this.databaseConfiguration.port());
    url.append("/");
    url.append(this.databaseConfiguration.databaseName());

    this.dataSource = new PGConnectionPoolDataSource();
    this.dataSource.setUrl(url.toString());
    this.dataSource.setUser(this.databaseConfiguration.ownerRoleName());
    this.dataSource.setPassword(this.databaseConfiguration.ownerRolePassword());
    this.dataSource.setDefaultAutoCommit(false);
    this.databases = new IdDatabases();
  }

  /**
   * The database cannot be opened if it has the wrong application ID.
   *
   * @throws Exception On errors
   */

  @Test
  public void testWrongApplicationId()
    throws Exception
  {
    try (var connection = this.dataSource.getConnection()) {
      try (var st = connection.prepareStatement(
        """
                    create table schema_version (
                      version_lock            char(1) not null default 'X',
                      version_application_id  text    not null,
                      version_number          bigint  not null,

                      constraint check_lock_primary primary key (version_lock),
                      constraint check_lock_locked check (version_lock = 'X')
                    )
          """)) {
        st.execute();
      }
      try (var st = connection.prepareStatement(
        """
                    insert into schema_version (version_application_id, version_number) values (?, ?)
          """)) {
        st.setString(1, "com.io7m.something_else");
        st.setLong(2, 0L);
        st.execute();
      }
    }

    final var telemetry =
      new IdDatabaseTelemetry(
        true,
        OpenTelemetry.noop()
          .getMeter("com.io7m.idstore"),
        OpenTelemetry.noop()
          .getTracer("com.io7m.idstore", IdVersion.MAIN_VERSION)
      );

    final var ex =
      assertThrows(IdDatabaseException.class, () -> {
        this.databases.open(
          this.databaseConfiguration,
          telemetry,
          s -> {
          }
        );
      });

    LOG.debug("message: {}", ex.getMessage());
    assertTrue(ex.getMessage().contains("com.io7m.something_else"));
  }

  /**
   * The database cannot be opened if it has the wrong version.
   *
   * @throws Exception On errors
   */

  @Test
  public void testWrongVersion()
    throws Exception
  {
    try (var connection = this.dataSource.getConnection()) {
      try (var st = connection.prepareStatement(
        """
                    create table schema_version (
                      version_lock            char(1) not null default 'X',
                      version_application_id  text    not null,
                      version_number          bigint  not null,

                      constraint check_lock_primary primary key (version_lock),
                      constraint check_lock_locked check (version_lock = 'X')
                    )
          """)) {
        st.execute();
      }
      try (var st = connection.prepareStatement(
        """
                    insert into schema_version (version_application_id, version_number) values (?, ?)
          """)) {
        st.setString(1, "com.io7m.idstore");
        st.setLong(2, (long) Integer.MAX_VALUE);
        st.execute();
      }
    }

    final var telemetry =
      new IdDatabaseTelemetry(
        true,
        OpenTelemetry.noop()
          .getMeter("com.io7m.idstore"),
        OpenTelemetry.noop()
          .getTracer("com.io7m.idstore", IdVersion.MAIN_VERSION)
      );

    final var ex =
      assertThrows(IdDatabaseException.class, () -> {
        this.databases.open(
          this.databaseConfiguration,
          telemetry,
          s -> {
          }
        );
      });

    LOG.debug("message: {}", ex.getMessage());
    assertTrue(ex.getMessage().contains("Database schema version is too high"));
  }

  /**
   * The database cannot be opened if the version table is malformed.
   *
   * @throws Exception On errors
   */

  @Test
  public void testInvalidVersion()
    throws Exception
  {
    try (var connection = this.dataSource.getConnection()) {
      try (var st = connection.prepareStatement(
        """
                    create table schema_version (
                      version_application_id  text    not null,
                      version_number          bigint  not null
                    )
          """)) {
        st.execute();
      }
    }

    final var telemetry =
      new IdDatabaseTelemetry(
        true,
        OpenTelemetry.noop()
          .getMeter("com.io7m.idstore"),
        OpenTelemetry.noop()
          .getTracer("com.io7m.idstore", IdVersion.MAIN_VERSION)
      );

    final var ex =
      assertThrows(IdDatabaseException.class, () -> {
        this.databases.open(
          this.databaseConfiguration,
          telemetry,
          s -> {
          }
        );
      });

    LOG.debug("message: {}", ex.getMessage());
    assertTrue(ex.getMessage().contains("schema_version table is empty"));
  }

  /**
   * The database cannot be opened if the version is too old, and upgrading
   * is not allowed.
   *
   * @throws Exception On errors
   */

  @Test
  public void testTooOld()
    throws Exception
  {
    try (var connection = this.dataSource.getConnection()) {
      try (var st = connection.prepareStatement(
        """
                    create table schema_version (
                      version_lock            char(1) not null default 'X',
                      version_application_id  text    not null,
                      version_number          bigint  not null,

                      constraint check_lock_primary primary key (version_lock),
                      constraint check_lock_locked check (version_lock = 'X')
                    )
          """)) {
        st.execute();
      }
      try (var st = connection.prepareStatement(
        """
                    insert into schema_version (version_application_id, version_number) values (?, ?)
          """)) {
        st.setString(1, "com.io7m.idstore");
        st.setLong(2, 0L);
        st.execute();
      }
    }

    final var telemetry =
      new IdDatabaseTelemetry(
        true,
        OpenTelemetry.noop()
          .getMeter("com.io7m.idstore"),
        OpenTelemetry.noop()
          .getTracer("com.io7m.idstore", IdVersion.MAIN_VERSION)
      );

    final var ex =
      assertThrows(IdDatabaseException.class, () -> {
        this.databases.open(
          this.databaseConfigurationWithoutUpgrades,
          telemetry,
          s -> {
          }
        );
      });

    LOG.debug("message: {}", ex.getMessage());
    assertTrue(ex.getMessage().contains("Incompatible database schema"));
  }
}