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

/**
 * Shared functions generally available for testing login components.
 */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
});

import { Assert as AssertCls } from "resource://testing-common/Assert.sys.mjs";

let Assert = AssertCls;

import { TestUtils } from "resource://testing-common/TestUtils.sys.mjs";
import { setTimeout } from "resource://gre/modules/Timer.sys.mjs";
import { FileTestUtils } from "resource://testing-common/FileTestUtils.sys.mjs";

const LoginInfo = Components.Constructor(
  "@mozilla.org/login-manager/loginInfo;1",
  "nsILoginInfo",
  "init"
);

export const LoginTestUtils = {
  setAssertReporter(reporterFunc) {
    Assert = new AssertCls(Cu.waiveXrays(reporterFunc));
  },

  /**
   * Forces the storage module to save all data, and the Login Manager service
   * to replace the storage module with a newly initialized instance.
   */
  async reloadData() {
    Services.obs.notifyObservers(null, "passwordmgr-storage-replace");
    await TestUtils.topicObserved("passwordmgr-storage-replace-complete");
  },

  /**
   * Erases all the data stored by the Login Manager service.
   */
  async clearData() {
    await Services.logins.removeAllUserFacingLoginsAsync();
    for (let origin of Services.logins.getAllDisabledHosts()) {
      Services.logins.setLoginSavingEnabled(origin, true);
    }
  },

  /**
   * Add a new login to the store
   */
  async addLogin({
    username,
    password,
    origin = "https://example.com",
    formActionOrigin,
  }) {
    const login = LoginTestUtils.testData.formLogin({
      origin,
      formActionOrigin: formActionOrigin || origin,
      username,
      password,
    });
    return Services.logins.addLoginAsync(login);
  },

  /**
   * Removes a login from the store
   */
  async removeLogin(login) {
    return Services.logins.removeLoginAsync(login);
  },

  async modifyLogin(oldLogin, newLogin) {
    const storageChangedPromise = TestUtils.topicObserved(
      "passwordmgr-storage-changed",
      (_, data) => data == "modifyLogin"
    );
    await Services.logins.modifyLoginAsync(oldLogin, newLogin);
    await storageChangedPromise;
  },

  resetGeneratedPasswordsCache() {
    let { LoginManagerParent } = ChromeUtils.importESModule(
      "resource://gre/modules/LoginManagerParent.sys.mjs"
    );
    LoginManagerParent.getGeneratedPasswordsByPrincipalOrigin().clear();
  },

  /**
   * Checks that the currently stored list of nsILoginInfo matches the provided
   * array.  If no `checkFn` is provided, the comparison uses the "equals"
   * method of nsILoginInfo, that does not include nsILoginMetaInfo properties in the test.
   */
  async checkLogins(expectedLogins, msg = "checkLogins", checkFn = undefined) {
    this.assertLoginListsEqual(
      await Services.logins.getAllLogins(),
      expectedLogins,
      msg,
      checkFn
    );
  },

  /**
   * Checks that the two provided arrays of nsILoginInfo have the same length,
   * and every login in "expected" is also found in "actual".  If no `checkFn`
   * is provided, the comparison uses the "equals" method of nsILoginInfo, that
   * does not include nsILoginMetaInfo properties in the test.
   */
  assertLoginListsEqual(
    actual,
    expected,
    msg = "assertLoginListsEqual",
    checkFn = undefined
  ) {
    Assert.equal(expected.length, actual.length, msg);
    Assert.ok(
      expected.every(e =>
        actual.some(a => {
          return checkFn ? checkFn(a, e) : a.equals(e);
        })
      ),
      msg
    );
  },

  /**
   * Checks that the two provided arrays of strings contain the same values,
   * maybe in a different order, case-sensitively.
   */
  assertDisabledHostsEqual(actual, expected) {
    Assert.deepEqual(actual.sort(), expected.sort());
  },

  /**
   * Checks whether the given time, expressed as the number of milliseconds
   * since January 1, 1970, 00:00:00 UTC, falls within 30 seconds of now.
   */
  assertTimeIsAboutNow(timeMs) {
    Assert.ok(Math.abs(timeMs - Date.now()) < 30000);
  },
};

/**
 * This object contains functions that return new instances of nsILoginInfo for
 * every call.  The returned instances can be compared using their "equals" or
 * "matches" methods, or modified for the needs of the specific test being run.
 *
 * Any modification to the test data requires updating the tests accordingly, in
 * particular the search tests.
 */
LoginTestUtils.testData = {
  /**
   * Returns a new nsILoginInfo for use with form submits.
   *
   * @param modifications
   *        Each property of this object replaces the property of the same name
   *        in the returned nsILoginInfo or nsILoginMetaInfo.
   */
  formLogin(modifications) {
    let loginInfo = new LoginInfo(
      "http://www3.example.com",
      "http://www.example.com",
      null,
      "the username",
      "the password",
      "form_field_username",
      "form_field_password"
    );
    loginInfo.QueryInterface(Ci.nsILoginMetaInfo);
    if (modifications) {
      for (let [name, value] of Object.entries(modifications)) {
        if (name == "httpRealm" && value !== null) {
          throw new Error("httpRealm not supported for form logins");
        }
        loginInfo[name] = value;
      }
    }
    return loginInfo;
  },

  /**
   * Returns a new nsILoginInfo for use with HTTP authentication.
   *
   * @param modifications
   *        Each property of this object replaces the property of the same name
   *        in the returned nsILoginInfo or nsILoginMetaInfo.
   */
  authLogin(modifications) {
    let loginInfo = new LoginInfo(
      "http://www.example.org",
      null,
      "The HTTP Realm",
      "the username",
      "the password"
    );
    loginInfo.QueryInterface(Ci.nsILoginMetaInfo);
    if (modifications) {
      for (let [name, value] of Object.entries(modifications)) {
        if (name == "formActionOrigin" && value !== null) {
          throw new Error(
            "formActionOrigin not supported for HTTP auth. logins"
          );
        }
        loginInfo[name] = value;
      }
    }
    return loginInfo;
  },

  /**
   * Returns an array of typical nsILoginInfo that could be stored in the
   * database.
   */
  loginList() {
    return [
      // --- Examples of form logins (subdomains of example.com) ---

      // Simple form login with named fields for username and password.
      new LoginInfo(
        "http://www.example.com",
        "http://www.example.com",
        null,
        "the username",
        "the password for www.example.com",
        "form_field_username",
        "form_field_password"
      ),

      // Different schemes are treated as completely different sites.
      new LoginInfo(
        "https://www.example.com",
        "https://www.example.com",
        null,
        "the username",
        "the password for https",
        "form_field_username",
        "form_field_password"
      ),

      // Subdomains can be treated as completely different sites depending on the UI invoked.
      new LoginInfo(
        "https://example.com",
        "https://example.com",
        null,
        "the username",
        "the password for example.com",
        "form_field_username",
        "form_field_password"
      ),

      // Forms found on the same origin, but with different origins in the
      // "action" attribute, are handled independently.
      new LoginInfo(
        "http://www3.example.com",
        "http://www.example.com",
        null,
        "the username",
        "the password",
        "form_field_username",
        "form_field_password"
      ),
      new LoginInfo(
        "http://www3.example.com",
        "https://www.example.com",
        null,
        "the username",
        "the password",
        "form_field_username",
        "form_field_password"
      ),
      new LoginInfo(
        "http://www3.example.com",
        "http://example.com",
        null,
        "the username",
        "the password",
        "form_field_username",
        "form_field_password"
      ),

      // It is not possible to store multiple passwords for the same username,
      // however multiple passwords can be stored when the usernames differ.
      // An empty username is a valid case and different from the others.
      new LoginInfo(
        "http://www4.example.com",
        "http://www4.example.com",
        null,
        "username one",
        "password one",
        "form_field_username",
        "form_field_password"
      ),
      new LoginInfo(
        "http://www4.example.com",
        "http://www4.example.com",
        null,
        "username two",
        "password two",
        "form_field_username",
        "form_field_password"
      ),
      new LoginInfo(
        "http://www4.example.com",
        "http://www4.example.com",
        null,
        "",
        "password three",
        "form_field_username",
        "form_field_password"
      ),

      // Username and passwords fields in forms may have no "name" attribute.
      new LoginInfo(
        "http://www5.example.com",
        "http://www5.example.com",
        null,
        "multi username",
        "multi password",
        "",
        ""
      ),

      // Forms with PIN-type authentication will typically have no username.
      new LoginInfo(
        "http://www6.example.com",
        "http://www6.example.com",
        null,
        "",
        "12345",
        "",
        "form_field_password"
      ),

      // Logins can be saved on non-default ports
      new LoginInfo(
        "https://www7.example.com:8080",
        "https://www7.example.com:8080",
        null,
        "8080_username",
        "8080_pass"
      ),

      new LoginInfo(
        "https://www7.example.com:8080",
        null,
        "My dev server",
        "8080_username2",
        "8080_pass2"
      ),

      // --- Examples of authentication logins (subdomains of example.org) ---

      // Simple HTTP authentication login.
      new LoginInfo(
        "http://www.example.org",
        null,
        "The HTTP Realm",
        "the username",
        "the password"
      ),

      // Simple FTP authentication login.
      new LoginInfo(
        "ftp://ftp.example.org",
        null,
        "ftp://ftp.example.org",
        "the username",
        "the password"
      ),

      // Multiple HTTP authentication logins can be stored for different realms.
      new LoginInfo(
        "http://www2.example.org",
        null,
        "The HTTP Realm",
        "the username",
        "the password"
      ),
      new LoginInfo(
        "http://www2.example.org",
        null,
        "The HTTP Realm Other",
        "the username other",
        "the password other"
      ),

      // --- Both form and authentication logins (example.net) ---

      new LoginInfo(
        "http://example.net",
        "http://example.net",
        null,
        "the username",
        "the password",
        "form_field_username",
        "form_field_password"
      ),
      new LoginInfo(
        "http://example.net",
        "http://www.example.net",
        null,
        "the username",
        "the password",
        "form_field_username",
        "form_field_password"
      ),
      new LoginInfo(
        "http://example.net",
        "http://www.example.net",
        null,
        "username two",
        "the password",
        "form_field_username",
        "form_field_password"
      ),
      new LoginInfo(
        "http://example.net",
        null,
        "The HTTP Realm",
        "the username",
        "the password"
      ),
      new LoginInfo(
        "http://example.net",
        null,
        "The HTTP Realm Other",
        "username two",
        "the password"
      ),
      new LoginInfo(
        "ftp://example.net",
        null,
        "ftp://example.net",
        "the username",
        "the password"
      ),
      // null formActionOrigin, empty httpRealm
      new LoginInfo(
        "http://example.net",
        null,
        "",
        "the username",
        "the password"
      ),

      // --- Examples of logins added by extensions (chrome scheme) ---

      new LoginInfo(
        "chrome://example_extension",
        null,
        "Example Login One",
        "the username",
        "the password one",
        "",
        ""
      ),
      new LoginInfo(
        "chrome://example_extension",
        null,
        "Example Login Two",
        "the username",
        "the password two"
      ),

      // -- file:// URIs throw accessing nsIURI.host

      new LoginInfo(
        "file://",
        "file://",
        null,
        "file: username",
        "file: password"
      ),

      // -- javascript: URIs throw accessing nsIURI.host.
      // They should only be used for the formActionOrigin.
      new LoginInfo(
        "https://js.example.com",
        "javascript:",
        null,
        "javascript: username",
        "javascript: password"
      ),
    ];
  },
};

LoginTestUtils.recipes = {
  getRecipeParent() {
    let { LoginManagerParent } = ChromeUtils.importESModule(
      "resource://gre/modules/LoginManagerParent.sys.mjs"
    );
    if (!LoginManagerParent.recipeParentPromise) {
      return null;
    }
    return LoginManagerParent.recipeParentPromise.then(recipeParent => {
      return recipeParent;
    });
  },
};

LoginTestUtils.primaryPassword = {
  primaryPassword: "omgsecret!",

  _set(enable, stayLoggedIn) {
    let oldPW, newPW;
    if (enable) {
      oldPW = "";
      newPW = this.primaryPassword;
    } else {
      oldPW = this.primaryPassword;
      newPW = "";
    }
    try {
      let token = Cc["@mozilla.org/security/internalkeytoken;1"].createInstance(
        Ci.nsIPKCS11Token
      );
      dump("MP change from " + oldPW + " to " + newPW + "\n");
      token.changePassword(oldPW, newPW);
      if (!stayLoggedIn) {
        token.logout();
      }
    } catch (e) {
      dump(
        "Tried to enable an already enabled primary password or disable an already disabled primary password!"
      );
    }
  },

  enable(stayLoggedIn = false) {
    this._set(true, stayLoggedIn);
  },

  disable() {
    this._set(false);
  },
};

/**
 * Utilities related to interacting with login fields in content.
 */
LoginTestUtils.loginField = {
  checkPasswordMasked(field, expected, msg) {
    let { editor } = field;
    let valueLength = field.value.length;
    Assert.equal(
      editor.autoMaskingEnabled,
      expected,
      `Check autoMaskingEnabled: ${msg}`
    );
    Assert.equal(editor.unmaskedStart, 0, `unmaskedStart is 0: ${msg}`);
    if (expected) {
      Assert.equal(editor.unmaskedEnd, 0, `Password is masked: ${msg}`);
    } else {
      Assert.equal(
        editor.unmaskedEnd,
        valueLength,
        `Unmasked to the end: ${msg}`
      );
    }
  },
};

LoginTestUtils.generation = {
  LENGTH: 15,
  REGEX: /^[a-km-np-zA-HJ-NP-Z2-9-~!@#$%^&*_+=)}:;"'>,.?\]]{15}$/,
};

LoginTestUtils.telemetry = {
  async waitForEventCount(
    count,
    process = "content",
    category = "pwmgr",
    method = undefined
  ) {
    // The test is already unreliable (see bug 1627419 and 1605494) and relied on
    // the implicit 100ms initial timer of waitForCondition that bug 1596165 removed.
    await new Promise(resolve => setTimeout(resolve, 100));
    let events = await TestUtils.waitForCondition(() => {
      let events = Services.telemetry.snapshotEvents(
        Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
        false
      )[process];

      if (!events) {
        return null;
      }

      events = events.filter(
        e => e[1] == category && (!method || e[2] == method)
      );
      dump(`Waiting for ${count} events, got ${events.length}\n`);
      return events.length == count ? events : null;
    }, "waiting for telemetry event count of: " + count);
    Assert.equal(events.length, count, "waiting for telemetry event count");
    return events;
  },
};

LoginTestUtils.file = {
  /**
   * Given an array of strings it creates a temporary CSV file that has them as content.
   *
   * @param {string[]} csvLines
   *        The lines that make up the CSV file.
   * @param {string} extension
   *        Optional parameter. Either 'csv' or 'tsv'. Default is 'csv'.
   * @returns {window.File} The File to the CSV file that was created.
   */
  async setupCsvFileWithLines(csvLines, extension = "csv") {
    let tmpFile = FileTestUtils.getTempFile(`firefox_logins.${extension}`);
    await IOUtils.writeUTF8(tmpFile.path, csvLines.join("\r\n"));
    return tmpFile;
  },
};

LoginTestUtils.remoteSettings = {
  relatedRealmsCollection: "websites-with-shared-credential-backends",
  async setupWebsitesWithSharedCredentials(
    relatedRealms = [["other-example.com", "example.com", "example.co.uk"]]
  ) {
    let db = lazy.RemoteSettings(this.relatedRealmsCollection).db;
    await db.clear();
    await db.create({
      id: "some-fake-ID-abc",
      relatedRealms,
    });
    await db.importChanges({}, Date.now());
  },
  async cleanWebsitesWithSharedCredentials() {
    let db = lazy.RemoteSettings(this.relatedRealmsCollection).db;
    await db.importChanges({}, Date.now(), [], { clear: true });
  },
  improvedPasswordRulesCollection: "password-rules",

  async setupImprovedPasswordRules(
    origin = "example.com",
    rules = "minlength: 6; maxlength: 16; required: lower, upper; required: digit; required: [&<>'\"!#$%(),:;=?[^`{|}~]]; max-consecutive: 2;"
  ) {
    let db = lazy.RemoteSettings(this.improvedPasswordRulesCollection).db;
    await db.clear();
    await db.create({
      id: "some-fake-ID",
      Domain: origin,
      "password-rules": rules,
    });
    await db.create({
      id: "some-fake-ID-2",
      Domain: origin,
      "password-rules": rules,
    });
    await db.importChanges({}, Date.now());
  },
  async cleanImprovedPasswordRules() {
    let db = lazy.RemoteSettings(this.improvedPasswordRulesCollection).db;
    await db.importChanges({}, Date.now(), [], { clear: true });
  },
};
