/*
 * Copyright 2022 Google LLC
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 *    * Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *    * Redistributions in binary form must reproduce the above
 * copyright notice, this list of conditions and the following disclaimer
 * in the documentation and/or other materials provided with the
 * distribution.
 *
 *    * Neither the name of Google LLC nor the names of its
 * contributors may be used to endorse or promote products derived from
 * this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package com.google.auth.oauth2;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

import com.google.api.client.json.GenericJson;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.Instant;
import org.junit.Test;

/** Tests for {@link ExecutableResponse}. */
public class ExecutableResponseTest {

  private static final String TOKEN_TYPE_OIDC = "urn:ietf:params:oauth:token-type:id_token";
  private static final String TOKEN_TYPE_SAML = "urn:ietf:params:oauth:token-type:saml2";
  private static final String ID_TOKEN = "header.payload.signature";
  private static final String SAML_RESPONSE = "samlResponse";

  private static final int EXECUTABLE_SUPPORTED_MAX_VERSION = 1;
  private static final int EXPIRATION_DURATION = 3600;

  @Test
  public void constructor_successOidcResponse() throws IOException {
    ExecutableResponse response = new ExecutableResponse(buildOidcResponse());

    assertTrue(response.isSuccessful());
    assertTrue(response.isValid());
    assertEquals(EXECUTABLE_SUPPORTED_MAX_VERSION, response.getVersion());
    assertEquals(TOKEN_TYPE_OIDC, response.getTokenType());
    assertEquals(ID_TOKEN, response.getSubjectToken());
    assertTrue(
        Instant.now().getEpochSecond() + EXPIRATION_DURATION == response.getExpirationTime());
  }

  @Test
  public void constructor_successOidcResponseMissingExpirationTimeField_notExpired()
      throws IOException {
    GenericJson jsonResponse = buildOidcResponse();
    jsonResponse.remove("expiration_time");

    ExecutableResponse response = new ExecutableResponse(jsonResponse);

    assertTrue(response.isSuccessful());
    assertTrue(response.isValid());
    assertFalse(response.isExpired());
    assertEquals(EXECUTABLE_SUPPORTED_MAX_VERSION, response.getVersion());
    assertEquals(TOKEN_TYPE_OIDC, response.getTokenType());
    assertEquals(ID_TOKEN, response.getSubjectToken());
    assertNull(response.getExpirationTime());
  }

  @Test
  public void constructor_successSamlResponse() throws IOException {
    ExecutableResponse response = new ExecutableResponse(buildSamlResponse());

    assertTrue(response.isSuccessful());
    assertTrue(response.isValid());
    assertEquals(EXECUTABLE_SUPPORTED_MAX_VERSION, response.getVersion());
    assertEquals(TOKEN_TYPE_SAML, response.getTokenType());
    assertEquals(SAML_RESPONSE, response.getSubjectToken());
    assertTrue(
        Instant.now().getEpochSecond() + EXPIRATION_DURATION == response.getExpirationTime());
  }

  @Test
  public void constructor_successSamlResponseMissingExpirationTimeField_notExpired()
      throws IOException {
    GenericJson jsonResponse = buildSamlResponse();
    jsonResponse.remove("expiration_time");

    ExecutableResponse response = new ExecutableResponse(jsonResponse);

    assertTrue(response.isSuccessful());
    assertTrue(response.isValid());
    assertFalse(response.isExpired());
    assertEquals(EXECUTABLE_SUPPORTED_MAX_VERSION, response.getVersion());
    assertEquals(TOKEN_TYPE_SAML, response.getTokenType());
    assertEquals(SAML_RESPONSE, response.getSubjectToken());
    assertNull(response.getExpirationTime());
  }

  @Test
  public void constructor_validErrorResponse() throws IOException {
    ExecutableResponse response = new ExecutableResponse(buildErrorResponse());

    assertFalse(response.isSuccessful());
    assertFalse(response.isValid());
    assertFalse(response.isExpired());
    assertNull(response.getSubjectToken());
    assertNull(response.getTokenType());
    assertNull(response.getExpirationTime());
    assertEquals(EXECUTABLE_SUPPORTED_MAX_VERSION, response.getVersion());
    assertEquals("401", response.getErrorCode());
    assertEquals("Caller not authorized.", response.getErrorMessage());
  }

  @Test
  public void constructor_errorResponseMissingCode_throws() throws IOException {
    GenericJson jsonResponse = buildErrorResponse();

    Object[] values = new Object[] {null, ""};
    for (Object value : values) {
      jsonResponse.put("code", value);
      try {
        new ExecutableResponse(jsonResponse);
        fail("Should not be able to continue without exception.");
      } catch (PluggableAuthException exception) {
        assertEquals(
            "Error code INVALID_EXECUTABLE_RESPONSE: The executable response must contain "
                + "`error` and `message` fields when unsuccessful.",
            exception.getMessage());
      }
    }
  }

  @Test
  public void constructor_errorResponseMissingMessage_throws() throws IOException {
    GenericJson jsonResponse = buildErrorResponse();

    Object[] values = new Object[] {null, ""};
    for (Object value : values) {
      jsonResponse.put("message", value);

      try {
        new ExecutableResponse(jsonResponse);
        fail("Should not be able to continue without exception.");
      } catch (PluggableAuthException exception) {
        assertEquals(
            "Error code INVALID_EXECUTABLE_RESPONSE: The executable response must contain "
                + "`error` and `message` fields when unsuccessful.",
            exception.getMessage());
      }
    }
  }

  @Test
  public void constructor_successResponseMissingVersionField_throws() throws IOException {
    GenericJson jsonResponse = buildOidcResponse();
    jsonResponse.remove("version");

    try {
      new ExecutableResponse(jsonResponse);
      fail("Should not be able to continue without exception.");
    } catch (PluggableAuthException exception) {
      assertEquals(
          "Error code INVALID_EXECUTABLE_RESPONSE: The executable response is missing the "
              + "`version` field.",
          exception.getMessage());
    }
  }

  @Test
  public void constructor_successResponseMissingSuccessField_throws() throws Exception {
    GenericJson jsonResponse = buildOidcResponse();
    jsonResponse.remove("success");

    try {
      new ExecutableResponse(jsonResponse);
      fail("Should not be able to continue without exception.");
    } catch (PluggableAuthException exception) {
      assertEquals(
          "Error code INVALID_EXECUTABLE_RESPONSE: The executable response is missing the "
              + "`success` field.",
          exception.getMessage());
    }
  }

  @Test
  public void constructor_successResponseMissingTokenTypeField_throws() throws IOException {
    GenericJson jsonResponse = buildOidcResponse();
    jsonResponse.remove("token_type");

    try {
      new ExecutableResponse(jsonResponse);
      fail("Should not be able to continue without exception.");
    } catch (PluggableAuthException exception) {
      assertEquals(
          "Error code INVALID_EXECUTABLE_RESPONSE: The executable response is missing the "
              + "`token_type` field.",
          exception.getMessage());
    }
  }

  @Test
  public void constructor_samlResponseMissingSubjectToken_throws() throws IOException {
    GenericJson jsonResponse = buildSamlResponse();

    Object[] values = new Object[] {null, ""};
    for (Object value : values) {
      jsonResponse.put("saml_response", value);

      try {
        new ExecutableResponse(jsonResponse);
        fail("Should not be able to continue without exception.");
      } catch (PluggableAuthException exception) {
        assertEquals(
            "Error code INVALID_EXECUTABLE_RESPONSE: The executable response does not "
                + "contain a valid token.",
            exception.getMessage());
      }
    }
  }

  @Test
  public void constructor_oidcResponseMissingSubjectToken_throws() throws IOException {
    GenericJson jsonResponse = buildOidcResponse();

    Object[] values = new Object[] {null, ""};
    for (Object value : values) {
      jsonResponse.put("id_token", value);

      try {
        new ExecutableResponse(jsonResponse);
        fail("Should not be able to continue without exception.");
      } catch (PluggableAuthException exception) {
        assertEquals(
            "Error code INVALID_EXECUTABLE_RESPONSE: The executable response does not "
                + "contain a valid token.",
            exception.getMessage());
      }
    }
  }

  @Test
  public void isExpired() throws IOException {
    GenericJson jsonResponse = buildOidcResponse();

    BigDecimal[] values =
        new BigDecimal[] {
          BigDecimal.valueOf(Instant.now().getEpochSecond() - 1000),
          BigDecimal.valueOf(Instant.now().getEpochSecond() + 1000)
        };
    boolean[] expectedResults = new boolean[] {true, false};

    for (int i = 0; i < values.length; i++) {
      jsonResponse.put("expiration_time", values[i]);

      ExecutableResponse response = new ExecutableResponse(jsonResponse);

      assertEquals(expectedResults[i], response.isExpired());
    }
  }

  private static GenericJson buildOidcResponse() {
    GenericJson json = new GenericJson();
    json.setFactory(OAuth2Utils.JSON_FACTORY);
    json.put("version", EXECUTABLE_SUPPORTED_MAX_VERSION);
    json.put("success", true);
    json.put("token_type", TOKEN_TYPE_OIDC);
    json.put("id_token", ID_TOKEN);
    json.put("expiration_time", Instant.now().getEpochSecond() + EXPIRATION_DURATION);
    return json;
  }

  private static GenericJson buildSamlResponse() {
    GenericJson json = new GenericJson();
    json.setFactory(OAuth2Utils.JSON_FACTORY);
    json.put("version", EXECUTABLE_SUPPORTED_MAX_VERSION);
    json.put("success", true);
    json.put("token_type", TOKEN_TYPE_SAML);
    json.put("saml_response", "samlResponse");
    json.put("expiration_time", Instant.now().getEpochSecond() + EXPIRATION_DURATION);
    return json;
  }

  private static GenericJson buildErrorResponse() {
    GenericJson json = new GenericJson();
    json.setFactory(OAuth2Utils.JSON_FACTORY);
    json.put("version", EXECUTABLE_SUPPORTED_MAX_VERSION);
    json.put("success", false);
    json.put("code", "401");
    json.put("message", "Caller not authorized.");
    return json;
  }
}
