/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/ */

/**
 * Tests methods that add, remove, and modify logins.
 */

"use strict";

// Globals

const MAX_DATE_MS = 8640000000000000;

/**
 * Verifies that the specified login is considered invalid by addLogin and by
 * modifyLogin with both nsILoginInfo and nsIPropertyBag arguments.
 *
 * This test requires that the login store is empty.
 *
 * @param aLoginInfo
 *        nsILoginInfo corresponding to an invalid login.
 * @param aExpectedError
 *        This argument is passed to the "Assert.throws" test to determine which
 *        error is expected from the modification functions.
 */
async function checkLoginInvalid(aLoginInfo, aExpectedError) {
  // Try to add the new login, and verify that no data is stored.
  await Assert.rejects(
    Services.logins.addLoginAsync(aLoginInfo),
    aExpectedError
  );
  LoginTestUtils.checkLogins([]);

  // Add a login for the modification tests.
  let testLogin = TestData.formLogin({ origin: "http://modify.example.com" });
  await Services.logins.addLoginAsync(testLogin);

  // Try to modify the existing login using nsILoginInfo and nsIPropertyBag.
  Assert.throws(
    () => Services.logins.modifyLogin(testLogin, aLoginInfo),
    aExpectedError
  );
  Assert.throws(
    () =>
      Services.logins.modifyLogin(
        testLogin,
        newPropertyBag({
          origin: aLoginInfo.origin,
          formActionOrigin: aLoginInfo.formActionOrigin,
          httpRealm: aLoginInfo.httpRealm,
          username: aLoginInfo.username,
          password: aLoginInfo.password,
          usernameField: aLoginInfo.usernameField,
          passwordField: aLoginInfo.passwordField,
        })
      ),
    aExpectedError
  );

  // Verify that no data was stored by the previous calls.
  LoginTestUtils.checkLogins([testLogin]);
  Services.logins.removeLogin(testLogin);
}

/**
 * Verifies that two objects are not the same instance
 * but have equal attributes.
 *
 * @param {Object} objectA
 *        An object to compare.
 *
 * @param {Object} objectB
 *        Another object to compare.
 *
 * @param {string[]} attributes
 *        Attributes to compare.
 *
 * @return true if all passed attributes are equal for both objects, false otherwise.
 */
function compareAttributes(objectA, objectB, attributes) {
  // If it's the same object, we want to return false.
  if (objectA == objectB) {
    return false;
  }
  return attributes.every(attr => objectA[attr] == objectB[attr]);
}

// Tests

/**
 * Tests that adding logins to the database works.
 */
add_task(async function test_addLogin_removeLogin() {
  // Each login from the test data should be valid and added to the list.
  await Services.logins.addLogins(TestData.loginList());
  LoginTestUtils.checkLogins(TestData.loginList());

  // Trying to add each login again should result in an error.
  for (let loginInfo of TestData.loginList()) {
    await Assert.rejects(
      Services.logins.addLoginAsync(loginInfo),
      /This login already exists./
    );
  }

  // Removing each login should succeed.
  for (let loginInfo of TestData.loginList()) {
    Services.logins.removeLogin(loginInfo);
  }

  LoginTestUtils.checkLogins([]);
});

add_task(async function add_login_works_with_empty_array() {
  const result = await Services.logins.addLogins([]);
  Assert.equal(result.length, 0, "no logins added");
});

add_task(async function duplicated_logins_are_not_added() {
  const login = TestData.formLogin({
    username: "user",
  });
  await Services.logins.addLogins([login]);
  const result = await Services.logins.addLogins([login]);
  Assert.equal(result, 0, "no logins added");
  Services.logins.removeAllUserFacingLogins();
});

add_task(async function logins_containing_nul_in_username_are_not_added() {
  const result = await Services.logins.addLogins([
    TestData.formLogin({ username: "user\0name" }),
  ]);
  Assert.equal(result, 0, "no logins added");
});

add_task(async function logins_containing_nul_in_password_are_not_added() {
  const result = await Services.logins.addLogins([
    TestData.formLogin({ password: "pass\0word" }),
  ]);
  Assert.equal(result, 0, "no logins added");
});

add_task(
  async function return_value_includes_plaintext_username_and_password() {
    const login = TestData.formLogin({});
    const [result] = await Services.logins.addLogins([login]);
    Assert.equal(result.username, login.username, "plaintext username is set");
    Assert.equal(result.password, login.password, "plaintext password is set");
    Services.logins.removeAllUserFacingLogins();
  }
);

add_task(async function event_data_includes_plaintext_username_and_password() {
  const login = TestData.formLogin({});
  const TestObserver = {
    QueryInterface: ChromeUtils.generateQI([
      "nsIObserver",
      "nsISupportsWeakReference",
    ]),
    observe(subject, topic, data) {
      Assert.ok(subject instanceof Ci.nsILoginInfo);
      Assert.ok(subject instanceof Ci.nsILoginMetaInfo);
      Assert.equal(
        subject.username,
        login.username,
        "plaintext username is set"
      );
      Assert.equal(
        subject.password,
        login.password,
        "plaintext password is set"
      );
    },
  };
  Services.obs.addObserver(TestObserver, "passwordmgr-storage-changed");
  await Services.logins.addLogins([login]);
  Services.obs.removeObserver(TestObserver, "passwordmgr-storage-changed");
  Services.logins.removeAllUserFacingLogins();
});

/**
 * Tests invalid combinations of httpRealm and formActionOrigin.
 *
 * For an nsILoginInfo to be valid for storage, one of the two properties should
 * be strictly equal to null, and the other must not be null or an empty string.
 *
 * The legacy case of an empty string in formActionOrigin and a null value in
 * httpRealm is also supported for storage at the moment.
 */
add_task(async function test_invalid_httpRealm_formActionOrigin() {
  // httpRealm === null, formActionOrigin === null
  await checkLoginInvalid(
    TestData.formLogin({ formActionOrigin: null }),
    /without a httpRealm or formActionOrigin/
  );

  // httpRealm === "", formActionOrigin === null
  await checkLoginInvalid(
    TestData.authLogin({ httpRealm: "" }),
    /without a httpRealm or formActionOrigin/
  );

  // httpRealm === null, formActionOrigin === ""
  // TODO: This is not enforced for now.
  // await checkLoginInvalid(TestData.formLogin({ formActionOrigin: "" }),
  //                   /without a httpRealm or formActionOrigin/);

  // httpRealm === "", formActionOrigin === ""
  let login = TestData.formLogin({ formActionOrigin: "" });
  login.httpRealm = "";
  await checkLoginInvalid(login, /both a httpRealm and formActionOrigin/);

  // !!httpRealm, !!formActionOrigin
  login = TestData.formLogin();
  login.httpRealm = "The HTTP Realm";
  await checkLoginInvalid(login, /both a httpRealm and formActionOrigin/);

  // httpRealm === "", !!formActionOrigin
  login = TestData.formLogin();
  login.httpRealm = "";
  await checkLoginInvalid(login, /both a httpRealm and formActionOrigin/);

  // !!httpRealm, formActionOrigin === ""
  login = TestData.authLogin();
  login.formActionOrigin = "";
  await checkLoginInvalid(login, /both a httpRealm and formActionOrigin/);
});

/**
 * Tests null or empty values in required login properties.
 */
add_task(async function test_missing_properties() {
  await checkLoginInvalid(
    TestData.formLogin({ origin: null }),
    /null or empty origin/
  );

  await checkLoginInvalid(
    TestData.formLogin({ origin: "" }),
    /null or empty origin/
  );

  await checkLoginInvalid(
    TestData.formLogin({ username: null }),
    /null username/
  );

  await checkLoginInvalid(
    TestData.formLogin({ password: null }),
    /null or empty password/
  );

  await checkLoginInvalid(
    TestData.formLogin({ password: "" }),
    /null or empty password/
  );
});

/**
 * Tests invalid NUL characters in nsILoginInfo properties.
 */
add_task(async function test_invalid_characters() {
  let loginList = [
    TestData.authLogin({ origin: "http://null\0X.example.com" }),
    TestData.authLogin({ httpRealm: "realm\0" }),
    TestData.formLogin({ formActionOrigin: "http://null\0X.example.com" }),
    TestData.formLogin({ usernameField: "field\0_null" }),
    TestData.formLogin({ usernameField: ".\0" }), // Special single dot case
    TestData.formLogin({ passwordField: "field\0_null" }),
    TestData.formLogin({ username: "user\0name" }),
    TestData.formLogin({ password: "pass\0word" }),
  ];
  for (let loginInfo of loginList) {
    await checkLoginInvalid(loginInfo, /login values can't contain nulls/);
  }
});

/**
 * Tests removing a login that does not exists.
 */
add_task(function test_removeLogin_nonexisting() {
  Assert.throws(
    () => Services.logins.removeLogin(TestData.formLogin()),
    /No matching logins/
  );
});

/**
 * Tests removing all logins at once.
 */
add_task(async function test_removeAllUserFacingLogins() {
  await Services.logins.addLogins(TestData.loginList());

  Services.logins.removeAllUserFacingLogins();
  LoginTestUtils.checkLogins([]);

  // The function should also work when there are no logins to delete.
  Services.logins.removeAllUserFacingLogins();
});

/**
 * Tests the modifyLogin function with an nsILoginInfo argument.
 */
add_task(async function test_modifyLogin_nsILoginInfo() {
  let loginInfo = TestData.formLogin();
  let updatedLoginInfo = TestData.formLogin({
    username: "new username",
    password: "new password",
    usernameField: "new_form_field_username",
    passwordField: "new_form_field_password",
  });
  let differentLoginInfo = TestData.authLogin();

  // Trying to modify a login that does not exist should throw.
  Assert.throws(
    () => Services.logins.modifyLogin(loginInfo, updatedLoginInfo),
    /No matching logins/
  );

  // Add the first form login, then modify it to match the second.
  await Services.logins.addLoginAsync(loginInfo);
  Services.logins.modifyLogin(loginInfo, updatedLoginInfo);

  // The data should now match the second login.
  LoginTestUtils.checkLogins([updatedLoginInfo]);
  Assert.throws(
    () => Services.logins.modifyLogin(loginInfo, updatedLoginInfo),
    /No matching logins/
  );

  // The login can be changed to have a different type and origin.
  Services.logins.modifyLogin(updatedLoginInfo, differentLoginInfo);
  LoginTestUtils.checkLogins([differentLoginInfo]);

  // It is now possible to add a login with the old type and origin.
  await Services.logins.addLoginAsync(loginInfo);
  LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]);

  // Modifying a login to match an existing one should not be possible.
  Assert.throws(
    () => Services.logins.modifyLogin(loginInfo, differentLoginInfo),
    /already exists/
  );
  LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]);

  LoginTestUtils.clearData();
});

/**
 * Tests the modifyLogin function with an nsIPropertyBag argument.
 */
add_task(async function test_modifyLogin_nsIProperyBag() {
  let loginInfo = TestData.formLogin();
  let updatedLoginInfo = TestData.formLogin({
    username: "new username",
    password: "new password",
    usernameField: "",
    passwordField: "new_form_field_password",
  });
  let differentLoginInfo = TestData.authLogin();
  let differentLoginProperties = newPropertyBag({
    origin: differentLoginInfo.origin,
    formActionOrigin: differentLoginInfo.formActionOrigin,
    httpRealm: differentLoginInfo.httpRealm,
    username: differentLoginInfo.username,
    password: differentLoginInfo.password,
    usernameField: differentLoginInfo.usernameField,
    passwordField: differentLoginInfo.passwordField,
  });

  // Trying to modify a login that does not exist should throw.
  Assert.throws(
    () => Services.logins.modifyLogin(loginInfo, newPropertyBag()),
    /No matching logins/
  );

  // Add the first form login, then modify it to match the second, changing
  // only some of its properties and checking the behavior with an empty string.
  await Services.logins.addLoginAsync(loginInfo);
  Services.logins.modifyLogin(
    loginInfo,
    newPropertyBag({
      username: "new username",
      password: "new password",
      usernameField: "",
      passwordField: "new_form_field_password",
    })
  );

  // The data should now match the second login.
  LoginTestUtils.checkLogins([updatedLoginInfo]);
  Assert.throws(
    () => Services.logins.modifyLogin(loginInfo, newPropertyBag()),
    /No matching logins/
  );

  // It is also possible to provide no properties to be modified.
  Services.logins.modifyLogin(updatedLoginInfo, newPropertyBag());

  // Specifying a null property for a required value should throw.
  Assert.throws(
    () =>
      Services.logins.modifyLogin(
        loginInfo,
        newPropertyBag({
          usernameField: null,
        })
      ),
    /No matching logins/
  );

  // The login can be changed to have a different type and origin.
  Services.logins.modifyLogin(updatedLoginInfo, differentLoginProperties);
  LoginTestUtils.checkLogins([differentLoginInfo]);

  // It is now possible to add a login with the old type and origin.
  await Services.logins.addLoginAsync(loginInfo);
  LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]);

  // Modifying a login to match an existing one should not be possible.
  Assert.throws(
    () => Services.logins.modifyLogin(loginInfo, differentLoginProperties),
    /already exists/
  );
  LoginTestUtils.checkLogins([loginInfo, differentLoginInfo]);

  LoginTestUtils.clearData();
});

/**
 * Tests the login deduplication function.
 */
add_task(function test_deduplicate_logins() {
  // Different key attributes combinations and the amount of unique
  // results expected for the TestData login list.
  let keyCombinations = [
    {
      keyset: ["username", "password"],
      results: 17,
    },
    {
      keyset: ["origin", "username"],
      results: 21,
    },
    {
      keyset: ["origin", "username", "password"],
      results: 22,
    },
    {
      keyset: ["origin", "username", "password", "formActionOrigin"],
      results: 27,
    },
  ];

  let logins = TestData.loginList();

  for (let testCase of keyCombinations) {
    // Deduplicate the logins using the current testcase keyset.
    let deduped = LoginHelper.dedupeLogins(logins, testCase.keyset);
    Assert.equal(
      deduped.length,
      testCase.results,
      "Correct amount of results."
    );

    // Checks that every login after deduping is unique.
    Assert.ok(
      deduped.every(loginA =>
        deduped.every(
          loginB => !compareAttributes(loginA, loginB, testCase.keyset)
        )
      ),
      "Every login is unique."
    );
  }
});

/**
 * Ensure that the login deduplication function keeps the most recent login.
 */
add_task(function test_deduplicate_keeps_most_recent() {
  // Logins to deduplicate.
  let logins = [
    TestData.formLogin({ timeLastUsed: Date.UTC(2004, 11, 4, 0, 0, 0) }),
    TestData.formLogin({
      formActionOrigin: "http://example.com",
      timeLastUsed: Date.UTC(2015, 11, 4, 0, 0, 0),
    }),
  ];

  // Deduplicate the logins.
  let deduped = LoginHelper.dedupeLogins(logins);
  Assert.equal(deduped.length, 1, "Deduplicated the logins array.");

  // Verify that the remaining login have the most recent date.
  let loginTimeLastUsed = deduped[0].QueryInterface(
    Ci.nsILoginMetaInfo
  ).timeLastUsed;
  Assert.equal(
    loginTimeLastUsed,
    Date.UTC(2015, 11, 4, 0, 0, 0),
    "Most recent login was kept."
  );

  // Deduplicate the reverse logins array.
  deduped = LoginHelper.dedupeLogins(logins.reverse());
  Assert.equal(deduped.length, 1, "Deduplicated the reversed logins array.");

  // Verify that the remaining login have the most recent date.
  loginTimeLastUsed = deduped[0].QueryInterface(
    Ci.nsILoginMetaInfo
  ).timeLastUsed;
  Assert.equal(
    loginTimeLastUsed,
    Date.UTC(2015, 11, 4, 0, 0, 0),
    "Most recent login was kept."
  );
});

/**
 * Tests handling when adding a login with bad date values
 */
add_task(async function test_addLogin_badDates() {
  LoginTestUtils.clearData();

  let now = Date.now();
  let defaultLoginDates = {
    timeCreated: now,
    timeLastUsed: now,
    timePasswordChanged: now,
  };

  let defaultsLogin = TestData.formLogin();
  for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) {
    Assert.ok(!defaultsLogin[pname]);
  }
  Assert.ok(
    !!(await Services.logins.addLoginAsync(defaultsLogin)),
    "Sanity check adding defaults formLogin"
  );
  Services.logins.removeAllUserFacingLogins();

  // 0 is a valid date in this context - new nsLoginInfo timestamps init to 0
  for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) {
    let loginInfo = TestData.formLogin(
      Object.assign({}, defaultLoginDates, {
        [pname]: 0,
      })
    );
    Assert.ok(
      !!(await Services.logins.addLoginAsync(loginInfo)),
      "Check 0 value for " + pname
    );
    Services.logins.removeAllUserFacingLogins();
  }

  // negative dates get clamped to 0 and are ok
  for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) {
    let loginInfo = TestData.formLogin(
      Object.assign({}, defaultLoginDates, {
        [pname]: -1,
      })
    );
    Assert.ok(
      !!(await Services.logins.addLoginAsync(loginInfo)),
      "Check -1 value for " + pname
    );
    Services.logins.removeAllUserFacingLogins();
  }

  // out-of-range dates will throw
  for (let pname of ["timeCreated", "timeLastUsed", "timePasswordChanged"]) {
    let loginInfo = TestData.formLogin(
      Object.assign({}, defaultLoginDates, {
        [pname]: MAX_DATE_MS + 1,
      })
    );
    await Assert.rejects(
      Services.logins.addLoginAsync(loginInfo),
      /invalid date properties/
    );
    Assert.equal(Services.logins.getAllLogins().length, 0);
  }

  LoginTestUtils.checkLogins([]);
});

/**
 * Tests handling when adding multiple logins with bad date values
 */
add_task(async function test_addLogins_badDates() {
  LoginTestUtils.clearData();

  let defaultsLogin = TestData.formLogin({
    username: "defaults",
  });
  await Services.logins.addLoginAsync(defaultsLogin);

  // -11644473600000 is the value you get if you convert Dec 31 1600 16:07:02 to unix epoch time
  let timeCreatedLogin = TestData.formLogin({
    username: "tc",
    timeCreated: -11644473600000,
  });
  await Assert.rejects(
    Services.logins.addLoginAsync(timeCreatedLogin),
    /Can\'t add a login with invalid date properties./
  );

  let timeLastUsedLogin = TestData.formLogin({
    username: "tlu",
    timeLastUsed: -11644473600000,
  });
  await Assert.rejects(
    Services.logins.addLoginAsync(timeLastUsedLogin),
    /Can\'t add a login with invalid date properties./
  );

  let timePasswordChangedLogin = TestData.formLogin({
    username: "tpc",
    timePasswordChanged: -11644473600000,
  });
  await Assert.rejects(
    Services.logins.addLoginAsync(timePasswordChangedLogin),
    /Can\'t add a login with invalid date properties./
  );

  Services.logins.removeAllUserFacingLogins();
});
