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

"use strict";

Cu.import("resource://services-common/utils.js");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/FxAccounts.jsm");
Cu.import("resource://gre/modules/FxAccountsClient.jsm");
Cu.import("resource://gre/modules/FxAccountsCommon.js");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Log.jsm");

const ONE_HOUR_MS = 1000 * 60 * 60;
const ONE_DAY_MS = ONE_HOUR_MS * 24;
const TWO_MINUTES_MS = 1000 * 60 * 2;

initTestLogging("Trace");

// XXX until bug 937114 is fixed
Cu.importGlobalProperties(['atob']);

let log = Log.repository.getLogger("Services.FxAccounts.test");
log.level = Log.Level.Debug;

// See verbose logging from FxAccounts.jsm
Services.prefs.setCharPref("identity.fxaccounts.loglevel", "DEBUG");

function run_test() {
  run_next_test();
}

/*
 * The FxAccountsClient communicates with the remote Firefox
 * Accounts auth server.  Mock the server calls, with a little
 * lag time to simulate some latency.
 *
 * We add the _verified attribute to mock the change in verification
 * state on the FXA server.
 */
function MockFxAccountsClient() {
  this._email = "nobody@example.com";
  this._verified = false;
  this._deletedOnServer = false; // for testing accountStatus

  // mock calls up to the auth server to determine whether the
  // user account has been verified
  this.recoveryEmailStatus = function (sessionToken) {
    // simulate a call to /recovery_email/status
    let deferred = Promise.defer();

    let response = {
      email: this._email,
      verified: this._verified
    };
    deferred.resolve(response);

    return deferred.promise;
  };

  this.accountStatus = function(uid) {
    let deferred = Promise.defer();
    deferred.resolve(!!uid && (!this._deletedOnServer));
    return deferred.promise;
  };

  this.accountKeys = function (keyFetchToken) {
    let deferred = Promise.defer();

    do_timeout(50, () => {
      let response = {
        kA: expandBytes("11"),
        wrapKB: expandBytes("22")
      };
      deferred.resolve(response);
    });
    return deferred.promise;
  };

  this.resendVerificationEmail = function(sessionToken) {
    // Return the session token to show that we received it in the first place
    return Promise.resolve(sessionToken);
  };

  this.signCertificate = function() { throw "no" };

  this.signOut = function() { return Promise.resolve(); };

  FxAccountsClient.apply(this);
}
MockFxAccountsClient.prototype = {
  __proto__: FxAccountsClient.prototype
}

let MockStorage = function() {
  this.data = null;
};
MockStorage.prototype = Object.freeze({
  set: function (contents) {
    this.data = contents;
    return Promise.resolve(null);
  },
  get: function () {
    return Promise.resolve(this.data);
  },
});

/*
 * We need to mock the FxAccounts module's interfaces to external
 * services, such as storage and the FxAccounts client.  We also
 * mock the now() method, so that we can simulate the passing of
 * time and verify that signatures expire correctly.
 */
function MockFxAccounts() {
  return new FxAccounts({
    _getCertificateSigned_calls: [],
    _d_signCertificate: Promise.defer(),
    _now_is: new Date(),
    signedInUserStorage: new MockStorage(),
    now: function () {
      return this._now_is;
    },
    getCertificateSigned: function (sessionToken, serializedPublicKey) {
      _("mock getCertificateSigned\n");
      this._getCertificateSigned_calls.push([sessionToken, serializedPublicKey]);
      return this._d_signCertificate.promise;
    },
    fxAccountsClient: new MockFxAccountsClient()
  });
}

add_test(function test_non_https_remote_server_uri() {
  Services.prefs.setCharPref(
    "identity.fxaccounts.remote.signup.uri",
    "http://example.com/browser/browser/base/content/test/general/accounts_testRemoteCommands.html");
  do_check_throws_message(function () {
    fxAccounts.getAccountsSignUpURI();
  }, "Firefox Accounts server must use HTTPS");

  Services.prefs.clearUserPref("identity.fxaccounts.remote.signup.uri");

  run_next_test();
});

add_task(function test_get_signed_in_user_initially_unset() {
  // This test, unlike the rest, uses an un-mocked FxAccounts instance.
  // However, we still need to pass an object to the constructor to
  // force it to expose "internal", so we can test the disk storage.
  let account = new FxAccounts({onlySetInternal: true})
  let credentials = {
    email: "foo@example.com",
    uid: "1234@lcip.org",
    assertion: "foobar",
    sessionToken: "dead",
    kA: "beef",
    kB: "cafe",
    verified: true
  };

  let result = yield account.getSignedInUser();
  do_check_eq(result, null);

  yield account.setSignedInUser(credentials);

  result = yield account.getSignedInUser();
  do_check_eq(result.email, credentials.email);
  do_check_eq(result.assertion, credentials.assertion);
  do_check_eq(result.kB, credentials.kB);

  // Delete the memory cache and force the user
  // to be read and parsed from storage (e.g. disk via JSONStorage).
  delete account.internal.signedInUser;
  result = yield account.getSignedInUser();
  do_check_eq(result.email, credentials.email);
  do_check_eq(result.assertion, credentials.assertion);
  do_check_eq(result.kB, credentials.kB);

  // sign out
  let localOnly = true;
  yield account.signOut(localOnly);

  // user should be undefined after sign out
  result = yield account.getSignedInUser();
  do_check_eq(result, null);
});

add_task(function test_getCertificate() {
  _("getCertificate()");
  // This test, unlike the rest, uses an un-mocked FxAccounts instance.
  // However, we still need to pass an object to the constructor to
  // force it to expose "internal".
  let fxa = new FxAccounts({onlySetInternal: true})
  let credentials = {
    email: "foo@example.com",
    uid: "1234@lcip.org",
    assertion: "foobar",
    sessionToken: "dead",
    kA: "beef",
    kB: "cafe",
    verified: true
  };
  yield fxa.setSignedInUser(credentials);

  // Test that an expired cert throws if we're offline.
  fxa.internal.currentAccountState.cert = {
    validUntil: Date.parse("Mon, 13 Jan 2000 21:45:06 GMT")
  };
  let offline = Services.io.offline;
  Services.io.offline = true;
  // This call would break from missing parameters ...
  fxa.internal.currentAccountState.getCertificate().then(
    result => {
      Services.io.offline = offline;
      do_throw("Unexpected success");
    },
    err => {
      Services.io.offline = offline;
      // ... so we have to check the error string.
      do_check_eq(err, "Error: OFFLINE");
    }
  );
  _("----- DONE ----\n");
});


// Sanity-check that our mocked client is working correctly
add_test(function test_client_mock() {
  do_test_pending();

  let fxa = new MockFxAccounts();
  let client = fxa.internal.fxAccountsClient;
  do_check_eq(client._verified, false);
  do_check_eq(typeof client.signIn, "function");

  // The recoveryEmailStatus function eventually fulfills its promise
  client.recoveryEmailStatus()
    .then(response => {
      do_check_eq(response.verified, false);
      do_test_finished();
      run_next_test();
    });
});

// Sign in a user, and after a little while, verify the user's email.
// Right after signing in the user, we should get the 'onlogin' notification.
// Polling should detect that the email is verified, and eventually
// 'onverified' should be observed
add_test(function test_verification_poll() {
  do_test_pending();

  let fxa = new MockFxAccounts();
  let test_user = getTestUser("francine");
  let login_notification_received = false;

  makeObserver(ONVERIFIED_NOTIFICATION, function() {
    log.debug("test_verification_poll observed onverified");
    // Once email verification is complete, we will observe onverified
    fxa.internal.getUserAccountData().then(user => {
      // And confirm that the user's state has changed
      do_check_eq(user.verified, true);
      do_check_eq(user.email, test_user.email);
      do_check_true(login_notification_received);
      do_test_finished();
      run_next_test();
    });
  });

  makeObserver(ONLOGIN_NOTIFICATION, function() {
    log.debug("test_verification_poll observer onlogin");
    login_notification_received = true;
  });

  fxa.setSignedInUser(test_user).then(() => {
    fxa.internal.getUserAccountData().then(user => {
      // The user is signing in, but email has not been verified yet
      do_check_eq(user.verified, false);
      do_timeout(200, function() {
        log.debug("Mocking verification of francine's email");
        fxa.internal.fxAccountsClient._email = test_user.email;
        fxa.internal.fxAccountsClient._verified = true;
      });
    });
  });
});

// Sign in the user, but never verify the email.  The check-email
// poll should time out.  No verifiedlogin event should be observed, and the
// internal whenVerified promise should be rejected
add_test(function test_polling_timeout() {
  do_test_pending();

  // This test could be better - the onverified observer might fire on
  // somebody else's stack, and we're not making sure that we're not receiving
  // such a message. In other words, this tests either failure, or success, but
  // not both.

  let fxa = new MockFxAccounts();
  let test_user = getTestUser("carol");

  let removeObserver = makeObserver(ONVERIFIED_NOTIFICATION, function() {
    do_throw("We should not be getting a login event!");
  });

  fxa.internal.POLL_SESSION = 1;

  let p = fxa.internal.whenVerified({});

  fxa.setSignedInUser(test_user).then(() => {
    p.then(
      (success) => {
        do_throw("this should not succeed");
      },
      (fail) => {
        removeObserver();
        do_test_finished();
        run_next_test();
      }
    );
  });
});

add_test(function test_getKeys() {
  do_test_pending();
  let fxa = new MockFxAccounts();
  let user = getTestUser("eusebius");

  // Once email has been verified, we will be able to get keys
  user.verified = true;

  fxa.setSignedInUser(user).then(() => {
    fxa.getSignedInUser().then((user) => {
      // Before getKeys, we have no keys
      do_check_eq(!!user.kA, false);
      do_check_eq(!!user.kB, false);
      // And we still have a key-fetch token and unwrapBKey to use
      do_check_eq(!!user.keyFetchToken, true);
      do_check_eq(!!user.unwrapBKey, true);

      fxa.internal.getKeys().then(() => {
        fxa.getSignedInUser().then((user) => {
          // Now we should have keys
          do_check_eq(fxa.internal.isUserEmailVerified(user), true);
          do_check_eq(!!user.verified, true);
          do_check_eq(user.kA, expandHex("11"));
          do_check_eq(user.kB, expandHex("66"));
          do_check_eq(user.keyFetchToken, undefined);
          do_check_eq(user.unwrapBKey, undefined);
          do_test_finished();
          run_next_test();
        });
      });
    });
  });
});

//  fetchAndUnwrapKeys with no keyFetchToken should trigger signOut
add_test(function test_fetchAndUnwrapKeys_no_token() {
  do_test_pending();

  let fxa = new MockFxAccounts();
  let user = getTestUser("lettuce.protheroe");
  delete user.keyFetchToken

  makeObserver(ONLOGOUT_NOTIFICATION, function() {
    log.debug("test_fetchAndUnwrapKeys_no_token observed logout");
    fxa.internal.getUserAccountData().then(user => {
      do_test_finished();
      run_next_test();
    });
  });

  fxa.setSignedInUser(user).then(
    user => {
      return fxa.internal.fetchAndUnwrapKeys();
    }
  ).then(
    null,
    error => {
      log.info("setSignedInUser correctly rejected");
    }
  )
});

// Alice (User A) signs up but never verifies her email.  Then Bob (User B)
// signs in with a verified email.  Ensure that no sign-in events are triggered
// on Alice's behalf.  In the end, Bob should be the signed-in user.
add_test(function test_overlapping_signins() {
  do_test_pending();

  let fxa = new MockFxAccounts();
  let alice = getTestUser("alice");
  let bob = getTestUser("bob");

  makeObserver(ONVERIFIED_NOTIFICATION, function() {
    log.debug("test_overlapping_signins observed onverified");
    // Once email verification is complete, we will observe onverified
    fxa.internal.getUserAccountData().then(user => {
      do_check_eq(user.email, bob.email);
      do_check_eq(user.verified, true);
      do_test_finished();
      run_next_test();
    });
  });

  // Alice is the user signing in; her email is unverified.
  fxa.setSignedInUser(alice).then(() => {
    log.debug("Alice signing in ...");
    fxa.internal.getUserAccountData().then(user => {
      do_check_eq(user.email, alice.email);
      do_check_eq(user.verified, false);
      log.debug("Alice has not verified her email ...");

      // Now Bob signs in instead and actually verifies his email
      log.debug("Bob signing in ...");
      fxa.setSignedInUser(bob).then(() => {
        do_timeout(200, function() {
          // Mock email verification ...
          log.debug("Bob verifying his email ...");
          fxa.internal.fxAccountsClient._verified = true;
        });
      });
    });
  });
});

add_task(function test_getAssertion() {
  let fxa = new MockFxAccounts();

  do_check_throws(function() {
    yield fxa.getAssertion("nonaudience");
  });

  let creds = {
    sessionToken: "sessionToken",
    kA: expandHex("11"),
    kB: expandHex("66"),
    verified: true
  };
  // By putting kA/kB/verified in "creds", we skip ahead
  // to the "we're ready" stage.
  yield fxa.setSignedInUser(creds);

  _("== ready to go\n");
  // Start with a nice arbitrary but realistic date.  Here we use a nice RFC
  // 1123 date string like we would get from an HTTP header. Over the course of
  // the test, we will update 'now', but leave 'start' where it is.
  let now = Date.parse("Mon, 13 Jan 2014 21:45:06 GMT");
  let start = now;
  fxa.internal._now_is = now;

  let d = fxa.getAssertion("audience.example.com");
  // At this point, a thread has been spawned to generate the keys.
  _("-- back from fxa.getAssertion\n");
  fxa.internal._d_signCertificate.resolve("cert1");
  let assertion = yield d;
  do_check_eq(fxa.internal._getCertificateSigned_calls.length, 1);
  do_check_eq(fxa.internal._getCertificateSigned_calls[0][0], "sessionToken");
  do_check_neq(assertion, null);
  _("ASSERTION: " + assertion + "\n");
  let pieces = assertion.split("~");
  do_check_eq(pieces[0], "cert1");
  let keyPair = fxa.internal.currentAccountState.keyPair;
  let cert = fxa.internal.currentAccountState.cert;
  do_check_neq(keyPair, undefined);
  _(keyPair.validUntil + "\n");
  let p2 = pieces[1].split(".");
  let header = JSON.parse(atob(p2[0]));
  _("HEADER: " + JSON.stringify(header) + "\n");
  do_check_eq(header.alg, "DS128");
  let payload = JSON.parse(atob(p2[1]));
  _("PAYLOAD: " + JSON.stringify(payload) + "\n");
  do_check_eq(payload.aud, "audience.example.com");
  do_check_eq(keyPair.validUntil, start + KEY_LIFETIME);
  do_check_eq(cert.validUntil, start + CERT_LIFETIME);
  _("delta: " + Date.parse(payload.exp - start) + "\n");
  let exp = Number(payload.exp);

  do_check_eq(exp, now + ASSERTION_LIFETIME);

  // Reset for next call.
  fxa.internal._d_signCertificate = Promise.defer();

  // Getting a new assertion "soon" (i.e., w/o incrementing "now"), even for
  // a new audience, should not provoke key generation or a signing request.
  assertion = yield fxa.getAssertion("other.example.com");

  // There were no additional calls - same number of getcert calls as before
  do_check_eq(fxa.internal._getCertificateSigned_calls.length, 1);

  // Wait an hour; assertion use period expires, but not the certificate
  now += ONE_HOUR_MS;
  fxa.internal._now_is = now;

  // This won't block on anything - will make an assertion, but not get a
  // new certificate.
  assertion = yield fxa.getAssertion("third.example.com");

  // Test will time out if that failed (i.e., if that had to go get a new cert)
  pieces = assertion.split("~");
  do_check_eq(pieces[0], "cert1");
  p2 = pieces[1].split(".");
  header = JSON.parse(atob(p2[0]));
  payload = JSON.parse(atob(p2[1]));
  do_check_eq(payload.aud, "third.example.com");

  // The keypair and cert should have the same validity as before, but the
  // expiration time of the assertion should be different.  We compare this to
  // the initial start time, to which they are relative, not the current value
  // of "now".

  keyPair = fxa.internal.currentAccountState.keyPair;
  cert = fxa.internal.currentAccountState.cert;
  do_check_eq(keyPair.validUntil, start + KEY_LIFETIME);
  do_check_eq(cert.validUntil, start + CERT_LIFETIME);
  exp = Number(payload.exp);
  do_check_eq(exp, now + ASSERTION_LIFETIME);

  // Now we wait even longer, and expect both assertion and cert to expire.  So
  // we will have to get a new keypair and cert.
  now += ONE_DAY_MS;
  fxa.internal._now_is = now;
  d = fxa.getAssertion("fourth.example.com");
  fxa.internal._d_signCertificate.resolve("cert2");
  assertion = yield d;
  do_check_eq(fxa.internal._getCertificateSigned_calls.length, 2);
  do_check_eq(fxa.internal._getCertificateSigned_calls[1][0], "sessionToken");
  pieces = assertion.split("~");
  do_check_eq(pieces[0], "cert2");
  p2 = pieces[1].split(".");
  header = JSON.parse(atob(p2[0]));
  payload = JSON.parse(atob(p2[1]));
  do_check_eq(payload.aud, "fourth.example.com");
  keyPair = fxa.internal.currentAccountState.keyPair;
  cert = fxa.internal.currentAccountState.cert;
  do_check_eq(keyPair.validUntil, now + KEY_LIFETIME);
  do_check_eq(cert.validUntil, now + CERT_LIFETIME);
  exp = Number(payload.exp);

  do_check_eq(exp, now + ASSERTION_LIFETIME);
  _("----- DONE ----\n");
});

add_task(function test_resend_email_not_signed_in() {
  let fxa = new MockFxAccounts();

  try {
    yield fxa.resendVerificationEmail();
  } catch(err) {
    do_check_eq(err.message,
      "Cannot resend verification email; no signed-in user");
    return;
  }
  do_throw("Should not be able to resend email when nobody is signed in");
});

add_test(function test_accountStatus() {
  let fxa = new MockFxAccounts();
  let alice = getTestUser("alice");

  // If we have no user, we have no account server-side
  fxa.accountStatus().then(
    (result) => {
      do_check_false(result);
    }
  ).then(
    () => {
      fxa.setSignedInUser(alice).then(
        () => {
          fxa.accountStatus().then(
            (result) => {
               // FxAccounts.accountStatus() should match Client.accountStatus()
               do_check_true(result);
               fxa.internal.fxAccountsClient._deletedOnServer = true;
               fxa.accountStatus().then(
                 (result) => {
                   do_check_false(result);
                   fxa.internal.fxAccountsClient._deletedOnServer = false;
                   run_next_test();
                 }
               );
            }
          )
        }
      );
    }
  );
});

add_test(function test_resend_email() {
  let fxa = new MockFxAccounts();
  let alice = getTestUser("alice");

  let initialState = fxa.internal.currentAccountState;

  // Alice is the user signing in; her email is unverified.
  fxa.setSignedInUser(alice).then(() => {
    log.debug("Alice signing in");

    // We're polling for the first email
    do_check_true(fxa.internal.currentAccountState !== initialState);
    let aliceState = fxa.internal.currentAccountState;

    // The polling timer is ticking
    do_check_true(fxa.internal.currentTimer > 0);

    fxa.internal.getUserAccountData().then(user => {
      do_check_eq(user.email, alice.email);
      do_check_eq(user.verified, false);
      log.debug("Alice wants verification email resent");

      fxa.resendVerificationEmail().then((result) => {
        // Mock server response; ensures that the session token actually was
        // passed to the client to make the hawk call
        do_check_eq(result, "alice's session token");

        // Timer was not restarted
        do_check_true(fxa.internal.currentAccountState === aliceState);

        // Timer is still ticking
        do_check_true(fxa.internal.currentTimer > 0);

        // Ok abort polling before we go on to the next test
        fxa.internal.abortExistingFlow();
        run_next_test();
      });
    });
  });
});

add_test(function test_sign_out() {
  do_test_pending();
  let fxa = new MockFxAccounts();
  let remoteSignOutCalled = false;
  let client = fxa.internal.fxAccountsClient;
  client.signOut = function() { remoteSignOutCalled = true; return Promise.resolve(); };
  makeObserver(ONLOGOUT_NOTIFICATION, function() {
    log.debug("test_sign_out_with_remote_error observed onlogout");
    // user should be undefined after sign out
    fxa.internal.getUserAccountData().then(user => {
      do_check_eq(user, null);
      do_check_true(remoteSignOutCalled);
      do_test_finished();
      run_next_test();
    });
  });
  fxa.signOut();
});

add_test(function test_sign_out_with_remote_error() {
  do_test_pending();
  let fxa = new MockFxAccounts();
  let client = fxa.internal.fxAccountsClient;
  let remoteSignOutCalled = false;
  // Force remote sign out to trigger an error
  client.signOut = function() { remoteSignOutCalled = true; throw "Remote sign out error"; };
  makeObserver(ONLOGOUT_NOTIFICATION, function() {
    log.debug("test_sign_out_with_remote_error observed onlogout");
    // user should be undefined after sign out
    fxa.internal.getUserAccountData().then(user => {
      do_check_eq(user, null);
      do_check_true(remoteSignOutCalled);
      do_test_finished();
      run_next_test();
    });
  });
  fxa.signOut();
});

/*
 * End of tests.
 * Utility functions follow.
 */

function expandHex(two_hex) {
  // Return a 64-character hex string, encoding 32 identical bytes.
  let eight_hex = two_hex + two_hex + two_hex + two_hex;
  let thirtytwo_hex = eight_hex + eight_hex + eight_hex + eight_hex;
  return thirtytwo_hex + thirtytwo_hex;
};

function expandBytes(two_hex) {
  return CommonUtils.hexToBytes(expandHex(two_hex));
};

function getTestUser(name) {
  return {
    email: name + "@example.com",
    uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348",
    sessionToken: name + "'s session token",
    keyFetchToken: name + "'s keyfetch token",
    unwrapBKey: expandHex("44"),
    verified: false
  };
}

function makeObserver(aObserveTopic, aObserveFunc) {
  let observer = {
    // nsISupports provides type management in C++
    // nsIObserver is to be an observer
    QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]),

    observe: function (aSubject, aTopic, aData) {
      log.debug("observed " + aTopic + " " + aData);
      if (aTopic == aObserveTopic) {
        removeMe();
        aObserveFunc(aSubject, aTopic, aData);
      }
    }
  };

  function removeMe() {
    log.debug("removing observer for " + aObserveTopic);
    Services.obs.removeObserver(observer, aObserveTopic);
  }

  Services.obs.addObserver(observer, aObserveTopic, false);
  return removeMe;
}

function do_check_throws(func, result, stack)
{
  if (!stack)
    stack = Components.stack.caller;

  try {
    func();
  } catch (ex) {
    if (ex.name == result) {
      return;
    }
    do_throw("Expected result " + result + ", caught " + ex, stack);
  }

  if (result) {
    do_throw("Expected result " + result + ", none thrown", stack);
  }
}
