IdServerUserViewIT.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.model.IdToken;
import com.io7m.idstore.server.api.IdServerRateLimitConfiguration;
import com.io7m.idstore.tests.extensions.IdTestDatabases;
import com.io7m.idstore.tests.extensions.IdTestServers;
import com.io7m.zelador.test_extension.CloseableResourcesType;
import com.io7m.zelador.test_extension.ZeladorExtension;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.io.IOException;
import java.net.CookieManager;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Objects;

import static java.net.http.HttpClient.Redirect.ALWAYS;
import static java.net.http.HttpClient.Redirect.NEVER;
import static java.net.http.HttpRequest.BodyPublishers.ofString;
import static java.net.http.HttpResponse.BodyHandlers.discarding;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

@Tag("integration")
@Tag("user-view")
@ExtendWith({ErvillaExtension.class, ZeladorExtension.class})
@ErvillaConfiguration(disabledIfUnsupported = true)
public final class IdServerUserViewIT
{
  private static IdTestDatabases.IdDatabaseFixture DATABASE_FIXTURE;
  private HttpClient httpClient;
  private CookieManager cookies;
  private HttpClient httpClientWithoutRedirects;
  private IdTestServers.IdTestServerFixture serverFixture;

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

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

    final var rateLimitConfiguration =
      new IdServerRateLimitConfiguration(
        Duration.ofSeconds(0L),
        Duration.ofSeconds(0L),
        Duration.ofSeconds(0L),
        Duration.ofSeconds(0L),
        Duration.ofSeconds(0L),
        Duration.ofSeconds(0L)
      );

    this.serverFixture =
      closeables.addPerTestResource(
        IdTestServers.createWithRateLimitConfiguration(
          DATABASE_FIXTURE,
          rateLimitConfiguration,
          10025,
          50000,
          50001,
          51000
        ));

    this.cookies =
      new CookieManager();

    this.httpClient =
      HttpClient.newBuilder()
        .followRedirects(ALWAYS)
        .cookieHandler(this.cookies)
        .build();

    this.httpClientWithoutRedirects =
      HttpClient.newBuilder()
        .followRedirects(NEVER)
        .cookieHandler(this.cookies)
        .build();
  }

  /**
   * Fetching CSS works.
   *
   * @throws Exception On errors
   */

  @Test
  public void testCSS()
    throws Exception
  {
    {
      final var req =
        HttpRequest.newBuilder(this.viewURL("/css/reset.css"))
          .build();
      final var res =
        this.httpClient.send(req, HttpResponse.BodyHandlers.ofString());

      assertTrue(res.body().startsWith(
        "/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */"));
    }

    {
      final var req =
        HttpRequest.newBuilder(this.viewURL("/css/style.css"))
          .build();
      final var res =
        this.httpClient.send(req, HttpResponse.BodyHandlers.ofString());

      assertTrue(res.body().contains("font-family:"));
    }
  }

  /**
   * Fetching the logo works.
   *
   * @throws Exception On errors
   */

  @Test
  public void testLogo()
    throws Exception
  {
    {
      final var req =
        HttpRequest.newBuilder(this.viewURL("/logo"))
          .build();
      final var res =
        this.httpClient.send(req, HttpResponse.BodyHandlers.ofString());

      assertTrue(res.body().contains("<svg width=\"64\""));
    }
  }

  /**
   * Logging in works.
   *
   * @throws Exception On errors
   */

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

    this.serverFixture.createUser(admin, "someone");

    this.login();
    this.logout();
  }

  /**
   * Logging in requires valid parameters.
   *
   * @throws Exception On errors
   */

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

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/login"))
        .POST(ofString("password=12345678"))
        .header(
          "Content-Type",
          "application/x-www-form-urlencoded"),
      200,
      "idstore: Login");
  }

  /**
   * Logging in requires valid parameters.
   *
   * @throws Exception On errors
   */

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

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/login"))
        .POST(ofString("username=someone"))
        .header(
          "Content-Type",
          "application/x-www-form-urlencoded"),
      200,
      "idstore: Login");
  }

  /**
   * Logging in requires valid parameters.
   *
   * @throws Exception On errors
   */

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

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/login"))
        .POST(ofString("username=nonexistent&password=12345678"))
        .header(
          "Content-Type",
          "application/x-www-form-urlencoded"),
      401,
      "idstore: Login");
  }

  /**
   * Logging in requires valid parameters.
   *
   * @throws Exception On errors
   */

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

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/login"))
        .POST(ofString("username=someone&password=1"))
        .header(
          "Content-Type",
          "application/x-www-form-urlencoded"),
      401,
      "idstore: Login");
  }

  /**
   * Adding an email works.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/email-add", "idstore: Add an email address.");
    this.openPage(
      "/email-add-run?email=extras@example.com",
      "idstore: Verification");

    this.completeEmailChallenge(
      "extras@example.com",
      "X-IDStore-Verification-Token-Permit",
      "/email-verification-permit/?token=%s"
    );
  }

  /**
   * Rejecting an email works.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/email-add", "idstore: Add an email address.");
    this.openPage(
      "/email-add-run?email=extras@example.com",
      "idstore: Verification");

    this.completeEmailChallenge(
      "extras@example.com",
      "X-IDStore-Verification-Token-Deny",
      "/email-verification-deny/?token=%s"
    );
  }

  /**
   * Email verification requires a token.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/email-add", "idstore: Add an email address.");

    this.expectError(
      HttpRequest.newBuilder(
        this.viewURL(
          "/email-verification-deny/?token=C0DE290A52CE988DAD77E16F60671830")),
      400,
      "idstore: Error");
  }

  /**
   * Email verification requires a token.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/email-add", "idstore: Add an email address.");

    this.expectError(
      HttpRequest.newBuilder(
        this.viewURL(
          "/email-verification-permit/?token=C0DE290A52CE988DAD77E16F60671830")),
      400,
      "idstore: Error");
  }

  /**
   * Email verification requires a token.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/email-add", "idstore: Add an email address.");

    this.expectError(
      HttpRequest.newBuilder(this.viewURL(
        "/email-verification-permit/?token=what")),
      400,
      "idstore: Error");
  }

  /**
   * Email verification requires a token.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/email-add", "idstore: Add an email address.");

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/email-verification-deny/?token=what")),
      400,
      "idstore: Error");
  }


  /**
   * Email verification requires a token.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/email-add", "idstore: Add an email address.");

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/email-verification-permit/")),
      400,
      "idstore: Error");
  }

  /**
   * Email verification requires a token.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/email-add", "idstore: Add an email address.");

    this.expectError(HttpRequest.newBuilder(
      this.viewURL("/email-verification-deny/")), 400, "idstore: Error");
  }

  /**
   * Starting email verification requires an email.
   *
   * @throws Exception On errors
   */

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

    this.login();

    this.expectError(HttpRequest.newBuilder(
      this.viewURL("/email-add-run")), 400, "idstore: Error");
  }

  /**
   * Starting email verification requires an email.
   *
   * @throws Exception On errors
   */

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

    this.login();

    this.expectError(HttpRequest.newBuilder(
      this.viewURL("/email-add-run/?email=*@*")), 400, "idstore: Error");
  }

  /**
   * Removing an email works.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/email-add", "idstore: Add an email address.");
    this.openPage(
      "/email-add-run?email=extras@example.com",
      "idstore: Verification");
    this.completeEmailChallenge(
      "extras@example.com",
      "X-IDStore-Verification-Token-Permit",
      "/email-verification-permit/?token=%s");
    this.openPage(
      "/email-remove-run?email=extras@example.com",
      "idstore: Verification");
    this.completeEmailChallenge(
      "extras@example.com",
      "X-IDStore-Verification-Token-Permit",
      "/email-verification-permit/?token=%s");
  }

  /**
   * Removing an email works.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/email-add", "idstore: Add an email address.");
    this.openPage(
      "/email-add-run?email=extras@example.com",
      "idstore: Verification");
    this.completeEmailChallenge(
      "extras@example.com",
      "X-IDStore-Verification-Token-Permit",
      "/email-verification-permit/?token=%s");

    this.openPage(
      "/email-remove-run?email=extras@example.com",
      "idstore: Verification");
    this.completeEmailChallenge(
      "extras@example.com",
      "X-IDStore-Verification-Token-Deny",
      "/email-verification-deny/?token=%s");
  }

  /**
   * Starting email verification requires an email.
   *
   * @throws Exception On errors
   */

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

    this.login();

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/email-remove-run")),
      400,
      "idstore: Error");
  }

  /**
   * Starting email verification requires an email.
   *
   * @throws Exception On errors
   */

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

    this.login();

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/email-remove-run/?email=*@*")),
      400,
      "idstore: Error");
  }

  /**
   * Authentication is required.
   *
   * @throws Exception On errors
   */

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

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/email-add")),
      401,
      "idstore: Login");
  }

  /**
   * Authentication is required.
   *
   * @throws Exception On errors
   */

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

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/email-remove")),
      401,
      "idstore: Login");
  }

  /**
   * Authentication is required.
   *
   * @throws Exception On errors
   */

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

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/email-add-run")),
      401,
      "idstore: Login");
  }

  /**
   * Authentication is required.
   *
   * @throws Exception On errors
   */

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

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/email-remove-run")),
      401,
      "idstore: Login");
  }

  /**
   * Authentication is required.
   *
   * @throws Exception On errors
   */

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

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/realname-update-run")),
      401,
      "idstore: Login");
  }

  /**
   * Authentication is required.
   *
   * @throws Exception On errors
   */

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

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/realname-update")),
      401,
      "idstore: Login");
  }

  /**
   * Updating a realname works.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/realname-update", "idstore: Update your real name.");
    this.openPage(
      "/realname-update-run?realname=Someone%20Else",
      "idstore: User Profile");

    final var user = this.serverFixture.getUser(userId);
    assertEquals("Someone Else", user.realName().value());
  }

  /**
   * Updating a realname fails for invalid names.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/realname-update", "idstore: Update your real name.");

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/realname-update-run?realname=")),
      400,
      "idstore: Error");
  }

  /**
   * Updating a realname fails for missing names.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/realname-update", "idstore: Update your real name.");

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/realname-update-run")),
      400,
      "idstore: Error");
  }

  /**
   * Authentication is required.
   *
   * @throws Exception On errors
   */

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

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/password-update-run")),
      401,
      "idstore: Login");
  }

  /**
   * Authentication is required.
   *
   * @throws Exception On errors
   */

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

    this.expectError(
      HttpRequest.newBuilder(this.viewURL("/password-update")),
      401,
      "idstore: Login");
  }

  /**
   * Updating a password works.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/password-update", "idstore: Update your password.");
    this.openPage(
      "/password-update-run?password0=abc&password1=abc",
      "idstore: Password Updated");
  }

  /**
   * Updating a password fails if the confirmation does not match.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/password-update", "idstore: Update your password.");

    this.expectError(
      HttpRequest.newBuilder(this.viewURL(
        "/password-update-run?password0=abc&password1=xyz")),
      400,
      "idstore: Error");
  }

  /**
   * Updating a password fails if the confirmation does not match.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/password-update", "idstore: Update your password.");

    this.expectError(
      HttpRequest.newBuilder(this.viewURL(
        "/password-update-run?password0=abc")),
      400,
      "idstore: Error");
  }

  /**
   * Updating a password fails if the confirmation does not match.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/password-update", "idstore: Update your password.");

    this.expectError(
      HttpRequest.newBuilder(this.viewURL(
        "/password-update-run?password1=xyz")),
      400,
      "idstore: Error");
  }

  /**
   * Resetting a password works.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/password-reset", "idstore: Reset password.");

    this.openPage(
      "/password-reset-run?username=someone&email=someone@example.com",
      "idstore: Password Reset"
    );

    final var mail =
      this.serverFixture.mailReceived().poll();
    final var token =
      mail.getHeader("X-IDStore-PasswordReset-Token")[0];

    this.openPage(
      "/password-reset-confirm?token=%s".formatted(token),
      "idstore: Reset password."
    );

    this.openPage(
      "/password-reset-confirm-run?password0=abc&password1=abc&token=%s"
        .formatted(token),
      "idstore: Password Reset"
    );

    this.loginWith("someone", "abc");
  }

  /**
   * Resetting a password fails with a bad token.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/password-reset", "idstore: Reset password.");

    this.openPage(
      "/password-reset-run?username=someone&email=someone@example.com",
      "idstore: Password Reset"
    );

    final var mail =
      this.serverFixture.mailReceived().poll();
    final var token =
      mail.getHeader("X-IDStore-PasswordReset-Token")[0];

    this.openPage(
      "/password-reset-confirm?token=%s".formatted(token),
      "idstore: Reset password."
    );

    this.expectError(
      HttpRequest.newBuilder(this.viewURL(
        "/password-reset-confirm-run?password0=abc&password1=abc&token=%s"
          .formatted(new StringBuilder(token).reverse()))),
      400,
      "idstore: Error"
    );
  }

  /**
   * Resetting a password fails with a bad token.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/password-reset", "idstore: Reset password.");

    this.openPage(
      "/password-reset-run?username=someone&email=someone@example.com",
      "idstore: Password Reset"
    );

    this.expectError(
      HttpRequest.newBuilder(this.viewURL(
        "/password-reset-confirm-run?password0=abc&password1=abc&token=%s"
          .formatted("not%20token"))),
      400,
      "idstore: Error"
    );
  }

  /**
   * Resetting a password fails with a bad username.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/password-reset", "idstore: Reset password.");

    this.expectError(
      HttpRequest.newBuilder(this.viewURL(
        "/password-reset-run?username=not%20user")),
      400,
      "idstore: Error"
    );
  }

  /**
   * Resetting a password fails with a bad email address.
   *
   * @throws Exception On errors
   */

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

    this.login();
    this.openPage("/password-reset", "idstore: Reset password.");

    this.expectError(
      HttpRequest.newBuilder(this.viewURL(
        "/password-reset-run?email=email@example")),
      400,
      "idstore: Error"
    );
  }

  private void expectError(
    final HttpRequest.Builder newBuilder,
    final int expected,
    final String Login)
    throws IOException, InterruptedException
  {
    final var req =
      newBuilder.build();
    final var res =
      this.httpClient.send(req, new IdXHTMLBodyHandler());

    final var titles =
      IdDocuments.elementsWithName(res.body(), "title");
    assertEquals(Login, titles.get(0).getTextContent());
    assertEquals(expected, res.statusCode());
  }

  private void completeEmailChallenge(
    final String emailTo,
    final String header,
    final String baseURL)
    throws Exception
  {
    final var received =
      List.copyOf(this.serverFixture.mailReceived());

    this.serverFixture.mailReceived().clear();

    IdToken token = null;
    for (final var email : received) {
      if (Objects.equals(email.getAllRecipients()[0].toString(), emailTo)) {
        token = new IdToken(
          email.getHeader(header)[0]
        );
        break;
      }
    }

    if (token == null) {
      throw new IllegalStateException("No email available with a token");
    }

    final var req =
      HttpRequest.newBuilder(
          this.viewURL(baseURL.formatted(token)))
        .build();
    final var res =
      this.httpClient.send(req, new IdXHTMLBodyHandler());
    assertEquals(200, res.statusCode());

    final var titles =
      IdDocuments.elementsWithName(res.body(), "title");
    assertEquals("idstore: Verified", titles.get(0).getTextContent());
  }

  private void openPage(
    final String endpoint,
    final String expectedTitle)
    throws IOException, InterruptedException
  {
    final var req =
      HttpRequest.newBuilder(this.viewURL(endpoint))
        .build();
    final var res =
      this.httpClient.send(req, new IdXHTMLBodyHandler());
    final var titles =
      IdDocuments.elementsWithName(res.body(), "title");
    assertEquals(expectedTitle, titles.get(0).getTextContent());
  }

  private URI viewURL(final String str)
  {
    return this.serverFixture.server().userView().resolve(str);
  }

  private void logout()
    throws IOException, InterruptedException
  {
    final var req =
      HttpRequest.newBuilder(this.viewURL("/logout"))
        .build();
    final var res =
      this.httpClientWithoutRedirects.send(req, discarding());
    assertEquals(302, res.statusCode());
  }

  private void login()
    throws IOException, InterruptedException
  {
    this.loginWith("someone", "12345678");
  }

  private void loginWith(
    final String username,
    final String password)
    throws IOException, InterruptedException
  {
    {
      final var req =
        HttpRequest.newBuilder(this.viewURL("/login"))
          .POST(ofString("username=%s&password=%s".formatted(
            username,
            password)))
          .header("Content-Type", "application/x-www-form-urlencoded")
          .build();
      final var res =
        this.httpClient.send(req, new IdXHTMLBodyHandler());
      assertEquals(200, res.statusCode());
    }

    this.openPage("/", "idstore: User Profile");
  }
}