IdUserLoginServiceTest.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.server.controller.user;

import com.io7m.idstore.database.api.IdDatabaseException;
import com.io7m.idstore.database.api.IdDatabaseTransactionType;
import com.io7m.idstore.database.api.IdDatabaseUsersQueriesType;
import com.io7m.idstore.model.IdBan;
import com.io7m.idstore.model.IdEmail;
import com.io7m.idstore.model.IdName;
import com.io7m.idstore.model.IdNonEmptyList;
import com.io7m.idstore.model.IdPassword;
import com.io7m.idstore.model.IdPasswordAlgorithmPBKDF2HmacSHA256;
import com.io7m.idstore.model.IdPasswordException;
import com.io7m.idstore.model.IdRealName;
import com.io7m.idstore.model.IdUser;
import com.io7m.idstore.server.api.IdServerConfigurations;
import com.io7m.idstore.server.controller.command_exec.IdCommandExecutionFailure;
import com.io7m.idstore.server.controller.user.IdUserLoginService;
import com.io7m.idstore.server.service.clock.IdServerClock;
import com.io7m.idstore.server.service.configuration.IdServerConfigurationFiles;
import com.io7m.idstore.server.service.configuration.IdServerConfigurationService;
import com.io7m.idstore.server.service.ratelimit.IdRateLimitUserLoginServiceType;
import com.io7m.idstore.server.service.sessions.IdSessionUserService;
import com.io7m.idstore.server.service.telemetry.api.IdEventServiceType;
import com.io7m.idstore.server.service.telemetry.api.IdEventUserLoggedIn;
import com.io7m.idstore.server.service.telemetry.api.IdEventUserLoginAuthenticationFailed;
import com.io7m.idstore.server.service.telemetry.api.IdEventUserLoginRateLimitExceeded;
import com.io7m.idstore.server.service.telemetry.api.IdMetricsService;
import com.io7m.idstore.server.service.telemetry.api.IdMetricsServiceType;
import com.io7m.idstore.server.service.telemetry.api.IdServerTelemetryNoOp;
import com.io7m.idstore.strings.IdStrings;
import com.io7m.idstore.tests.IdFakeClock;
import com.io7m.idstore.tests.IdTestDirectories;
import com.io7m.idstore.tests.server.api.IdServerConfigurationsTest;
import com.io7m.idstore.tests.server.service.IdServiceContract;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.internal.verification.Times;

import java.io.IOException;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;

import static com.io7m.idstore.error_codes.IdStandardErrorCodes.AUTHENTICATION_ERROR;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.BANNED;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.RATE_LIMIT_EXCEEDED;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.SQL_ERROR;
import static com.io7m.idstore.error_codes.IdStandardErrorCodes.USER_NONEXISTENT;
import static java.util.Optional.empty;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;

public final class IdUserLoginServiceTest extends IdServiceContract<IdUserLoginService>
{
  private IdFakeClock clock;
  private IdServerClock serverClock;
  private IdStrings strings;
  private IdSessionUserService sessions;
  private IdUserLoginService login;
  private IdDatabaseTransactionType transaction;
  private IdDatabaseUsersQueriesType users;
  private IdServerConfigurationService configurationService;
  private Path directory;
  private IdRateLimitUserLoginServiceType rateLimit;
  private IdEventServiceType events;
  private IdMetricsServiceType metrics;

  private static Times once()
  {
    return new Times(1);
  }

  private IdPassword password()
  {
    try {
      return IdPasswordAlgorithmPBKDF2HmacSHA256.create().createHashed("x");
    } catch (final IdPasswordException e) {
      throw new IllegalStateException(e);
    }
  }

  private IdUser createUser(
    final String name)
  {
    return new IdUser(
      UUID.randomUUID(),
      new IdName(name),
      new IdRealName("User " + name),
      IdNonEmptyList.single(new IdEmail(name + "@example.com")),
      OffsetDateTime.now(),
      OffsetDateTime.now(),
      this.password()
    );
  }

  @BeforeEach
  public void setup()
    throws Exception
  {
    this.directory =
      IdTestDirectories.createTempDirectory();

    final var file =
      IdTestDirectories.resourceOf(
        IdServerConfigurationsTest.class,
        this.directory,
        "server-config-0.xml"
      );

    final var configFile =
      new IdServerConfigurationFiles()
        .parse(file);

    final var configuration =
      IdServerConfigurations.ofFile(
        Locale.getDefault(),
        Clock.systemUTC(),
        configFile
      );

    this.clock =
      new IdFakeClock();
    this.serverClock =
      new IdServerClock(this.clock);
    this.strings =
      IdStrings.create(Locale.ROOT);
    this.sessions =
      new IdSessionUserService(
        new IdMetricsService(IdServerTelemetryNoOp.noop()),
        Duration.ofDays(1L)
      );
    this.rateLimit =
      mock(IdRateLimitUserLoginServiceType.class);
    this.events =
      mock(IdEventServiceType.class);

    when(this.rateLimit.isAllowedByRateLimit(any()))
      .thenReturn(Boolean.TRUE);

    this.metrics =
      mock(IdMetricsServiceType.class);
    this.configurationService =
      new IdServerConfigurationService(this.metrics, configuration);
    this.login =
      new IdUserLoginService(
        this.serverClock,
        this.strings,
        this.sessions,
        this.configurationService,
        this.rateLimit,
        this.events
      );
    this.transaction =
      mock(IdDatabaseTransactionType.class);
    this.users =
      mock(IdDatabaseUsersQueriesType.class);

    when(this.transaction.queries(IdDatabaseUsersQueriesType.class))
      .thenReturn(this.users);
  }

  @AfterEach
  public void tearDown()
    throws IOException
  {
    IdTestDirectories.deleteDirectory(this.directory);
  }

  /**
   * Nonexistent users cannot log in.
   *
   * @throws Exception On errors
   */

  @Test
  public void testUserNonexistent()
    throws Exception
  {
    when(this.users.userGetForNameRequire(any()))
      .thenThrow(new IdDatabaseException("", USER_NONEXISTENT, Map.of(), empty()));

    final var ex =
      assertThrows(IdCommandExecutionFailure.class, () -> {
        this.login.userLogin(
          this.transaction,
          UUID.randomUUID(),
          "127.0.0.1",
          "nonexistent",
          "password",
          Map.of()
        );
      });

    assertEquals(AUTHENTICATION_ERROR, ex.errorCode());

    verify(this.users, once()).userGetForNameRequire(any());
    verifyNoMoreInteractions(this.users);
    verifyNoMoreInteractions(this.events);
  }

  /**
   * Database errors fail logins.
   *
   * @throws Exception On errors
   */

  @Test
  public void testDatabaseError0()
    throws Exception
  {
    when(this.users.userGetForNameRequire(any()))
      .thenThrow(new IdDatabaseException("", SQL_ERROR, Map.of(), empty()));

    final var ex =
      assertThrows(IdCommandExecutionFailure.class, () -> {
        this.login.userLogin(
          this.transaction,
          UUID.randomUUID(),
          "127.0.0.1",
          "nonexistent",
          "password",
          Map.of()
        );
      });

    assertEquals(SQL_ERROR, ex.errorCode());

    verify(this.users, once()).userGetForNameRequire(any());
    verifyNoMoreInteractions(this.users);
    verifyNoMoreInteractions(this.events);
  }

  /**
   * Banned users cannot log in.
   *
   * @throws Exception On errors
   */

  @Test
  public void testUserBanned()
    throws Exception
  {
    final var admin =
      this.createUser("user");
    final var ban =
      new IdBan(admin.id(), "No reason.", empty());

    when(this.users.userGetForNameRequire(any()))
      .thenReturn(admin);
    when(this.users.userBanGet(any()))
      .thenReturn(Optional.of(ban));

    final var ex =
      assertThrows(IdCommandExecutionFailure.class, () -> {
        this.login.userLogin(
          this.transaction,
          UUID.randomUUID(),
          "127.0.0.1",
          "user",
          "password",
          Map.of()
        );
      });

    assertEquals(BANNED, ex.errorCode());

    verify(this.users, once()).userGetForNameRequire(any());
    verify(this.users, once()).userBanGet(any());
    verifyNoMoreInteractions(this.users);
    verifyNoMoreInteractions(this.events);
  }

  /**
   * Banned users cannot log in.
   *
   * @throws Exception On errors
   */

  @Test
  public void testUserBannedNotExpired()
    throws Exception
  {
    final var admin =
      this.createUser("user");
    final var ban =
      new IdBan(
        admin.id(),
        "No reason.",
        Optional.of(this.serverClock.now().plusHours(1L)));

    when(this.users.userGetForNameRequire(any()))
      .thenReturn(admin);
    when(this.users.userBanGet(any()))
      .thenReturn(Optional.of(ban));

    final var ex =
      assertThrows(IdCommandExecutionFailure.class, () -> {
        this.login.userLogin(
          this.transaction,
          UUID.randomUUID(),
          "127.0.0.1",
          "user",
          "password",
          Map.of()
        );
      });

    assertEquals(BANNED, ex.errorCode());

    verify(this.users, once()).userGetForNameRequire(any());
    verify(this.users, once()).userBanGet(any());
    verifyNoMoreInteractions(this.users);
    verifyNoMoreInteractions(this.events);
  }

  /**
   * Incorrect passwords fail.
   *
   * @throws Exception On errors
   */

  @Test
  public void testUserWrongPassword()
    throws Exception
  {
    final var user =
      this.createUser("user");

    when(this.users.userGetForNameRequire(any()))
      .thenReturn(user);
    when(this.users.userBanGet(any()))
      .thenReturn(empty());

    final var ex =
      assertThrows(IdCommandExecutionFailure.class, () -> {
        this.login.userLogin(
          this.transaction,
          UUID.randomUUID(),
          "127.0.0.1",
          "user",
          "not the password",
          Map.of()
        );
      });

    assertEquals(AUTHENTICATION_ERROR, ex.errorCode());

    verify(this.users, once()).userGetForNameRequire(any());
    verify(this.users, once()).userBanGet(any());
    verifyNoMoreInteractions(this.users);

    verify(this.events, once())
      .emit(new IdEventUserLoginAuthenticationFailed("127.0.0.1", user.id()));

    verifyNoMoreInteractions(this.events);
  }

  /**
   * Rate limiting rejects logins.
   *
   * @throws Exception On errors
   */

  @Test
  public void testUserRateLimited()
    throws Exception
  {
    final var admin =
      this.createUser("user");

    when(this.users.userGetForNameRequire(any()))
      .thenReturn(admin);
    when(this.users.userBanGet(any()))
      .thenReturn(empty());
    when(this.rateLimit.isAllowedByRateLimit(any()))
      .thenReturn(Boolean.FALSE);

    final var ex =
      assertThrows(IdCommandExecutionFailure.class, () -> {
        this.login.userLogin(
          this.transaction,
          UUID.randomUUID(),
          "127.0.0.1",
          "user",
          "x",
          Map.of()
        );
      });

    assertEquals(RATE_LIMIT_EXCEEDED, ex.errorCode());

    verifyNoMoreInteractions(this.users);
    verify(this.events, once())
      .emit(new IdEventUserLoginRateLimitExceeded("127.0.0.1", "user"));
  }

  /**
   * Correct passwords succeed.
   *
   * @throws Exception On errors
   */

  @Test
  public void testUserCorrectPassword()
    throws Exception
  {
    final var user =
      this.createUser("user");

    when(this.users.userGetForNameRequire(any()))
      .thenReturn(user);
    when(this.users.userBanGet(any()))
      .thenReturn(empty());

    final var loggedIn =
      this.login.userLogin(
        this.transaction,
        UUID.randomUUID(),
        "127.0.0.1",
        "user",
        "x",
        Map.of()
      );

    assertTrue(this.sessions.findSession(loggedIn.session().id()).isPresent());
    assertEquals(user.withRedactedPassword(), loggedIn.user());

    verify(this.users, once()).userGetForNameRequire(any());
    verify(this.users, once()).userBanGet(any());
    verify(this.users, once()).userLogin(any(), any(), anyInt());
    verifyNoMoreInteractions(this.users);

    verify(this.events, once())
      .emit(new IdEventUserLoggedIn(user.id()));
    verifyNoMoreInteractions(this.events);
  }

  @Override
  protected IdUserLoginService createInstanceA()
  {
    return new IdUserLoginService(
      this.serverClock,
      this.strings,
      this.sessions,
      this.configurationService,
      this.rateLimit,
      this.events
    );
  }

  @Override
  protected IdUserLoginService createInstanceB()
  {
    return new IdUserLoginService(
      this.serverClock,
      this.strings,
      this.sessions,
      this.configurationService,
      this.rateLimit,
      this.events
    );
  }
}