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

const { Service } = ChromeUtils.importESModule(
  "resource://services-sync/service.sys.mjs"
);
const { WBORecord } = ChromeUtils.importESModule(
  "resource://services-sync/record.sys.mjs"
);
const { Resource } = ChromeUtils.importESModule(
  "resource://services-sync/resource.sys.mjs"
);
const { RotaryEngine } = ChromeUtils.importESModule(
  "resource://testing-common/services/sync/rotaryengine.sys.mjs"
);
const { getFxAccountsSingleton } = ChromeUtils.importESModule(
  "resource://gre/modules/FxAccounts.sys.mjs"
);
const fxAccounts = getFxAccountsSingleton();

function SteamStore(engine) {
  Store.call(this, "Steam", engine);
}
Object.setPrototypeOf(SteamStore.prototype, Store.prototype);

function SteamTracker(name, engine) {
  LegacyTracker.call(this, name || "Steam", engine);
}
Object.setPrototypeOf(SteamTracker.prototype, LegacyTracker.prototype);

function SteamEngine(service) {
  SyncEngine.call(this, "steam", service);
}

SteamEngine.prototype = {
  _storeObj: SteamStore,
  _trackerObj: SteamTracker,
  _errToThrow: null,
  problemsToReport: null,
  async _sync() {
    if (this._errToThrow) {
      throw this._errToThrow;
    }
  },
  getValidator() {
    return new SteamValidator();
  },
};
Object.setPrototypeOf(SteamEngine.prototype, SyncEngine.prototype);

function BogusEngine(service) {
  SyncEngine.call(this, "bogus", service);
}

BogusEngine.prototype = Object.create(SteamEngine.prototype);

class SteamValidator {
  async canValidate() {
    return true;
  }

  async validate(engine) {
    return {
      problems: new SteamValidationProblemData(engine.problemsToReport),
      version: 1,
      duration: 0,
      recordCount: 0,
    };
  }
}

class SteamValidationProblemData {
  constructor(problemsToReport = []) {
    this.problemsToReport = problemsToReport;
  }

  getSummary() {
    return this.problemsToReport;
  }
}

async function cleanAndGo(engine, server) {
  await engine._tracker.clearChangedIDs();
  Svc.Prefs.resetBranch("");
  syncTestLogging();
  Service.recordManager.clearCache();
  await promiseStopServer(server);
}

add_task(async function setup() {
  // Avoid addon manager complaining about not being initialized
  await Service.engineManager.unregister("addons");
  await Service.engineManager.unregister("extension-storage");
});

add_task(async function test_basic() {
  enableValidationPrefs();

  let helper = track_collections_helper();
  let upd = helper.with_updated_collection;

  let handlers = {
    "/1.1/johndoe/info/collections": helper.handler,
    "/1.1/johndoe/storage/crypto/keys": upd(
      "crypto",
      new ServerWBO("keys").handler()
    ),
    "/1.1/johndoe/storage/meta/global": upd(
      "meta",
      new ServerWBO("global").handler()
    ),
  };

  let collections = [
    "clients",
    "bookmarks",
    "forms",
    "history",
    "passwords",
    "prefs",
    "tabs",
  ];

  for (let coll of collections) {
    handlers["/1.1/johndoe/storage/" + coll] = upd(
      coll,
      new ServerCollection({}, true).handler()
    );
  }

  let server = httpd_setup(handlers);
  await configureIdentity({ username: "johndoe" }, server);

  let ping = await wait_for_ping(() => Service.sync(), true, true);

  // Check the "os" block - we can't really check specific values, but can
  // check it smells sane.
  ok(ping.os, "there is an OS block");
  ok("name" in ping.os, "there is an OS name");
  ok("version" in ping.os, "there is an OS version");
  ok("locale" in ping.os, "there is an OS locale");

  Svc.Prefs.resetBranch("");
  await promiseStopServer(server);
});

add_task(async function test_processIncoming_error() {
  let engine = Service.engineManager.get("bookmarks");
  await engine.initialize();
  let store = engine._store;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let collection = server.user("foo").collection("bookmarks");
  try {
    // Create a bogus record that when synced down will provoke a
    // network error which in turn provokes an exception in _processIncoming.
    const BOGUS_GUID = "zzzzzzzzzzzz";
    let bogus_record = collection.insert(BOGUS_GUID, "I'm a bogus record!");
    bogus_record.get = function get() {
      throw new Error("Sync this!");
    };
    // Make the 10 minutes old so it will only be synced in the toFetch phase.
    bogus_record.modified = Date.now() / 1000 - 60 * 10;
    await engine.setLastSync(Date.now() / 1000 - 60);
    engine.toFetch = new SerializableSet([BOGUS_GUID]);

    let error, pingPayload, fullPing;
    try {
      await sync_engine_and_validate_telem(
        engine,
        true,
        (errPing, fullErrPing) => {
          pingPayload = errPing;
          fullPing = fullErrPing;
        }
      );
    } catch (ex) {
      error = ex;
    }
    ok(!!error);
    ok(!!pingPayload);

    equal(fullPing.uid, "f".repeat(32)); // as setup by SyncTestingInfrastructure
    deepEqual(pingPayload.failureReason, {
      name: "httperror",
      code: 500,
    });

    equal(pingPayload.engines.length, 1);

    equal(pingPayload.engines[0].name, "bookmarks-buffered");
    deepEqual(pingPayload.engines[0].failureReason, {
      name: "httperror",
      code: 500,
    });
  } finally {
    await store.wipe();
    await cleanAndGo(engine, server);
  }
});

add_task(async function test_uploading() {
  let engine = Service.engineManager.get("bookmarks");
  await engine.initialize();
  let store = engine._store;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  let bmk = await PlacesUtils.bookmarks.insert({
    parentGuid: PlacesUtils.bookmarks.toolbarGuid,
    url: "http://getfirefox.com/",
    title: "Get Firefox!",
  });

  try {
    let ping = await sync_engine_and_validate_telem(engine, false);
    ok(!!ping);
    equal(ping.engines.length, 1);
    equal(ping.engines[0].name, "bookmarks-buffered");
    ok(!!ping.engines[0].outgoing);
    greater(ping.engines[0].outgoing[0].sent, 0);
    ok(!ping.engines[0].incoming);

    await PlacesUtils.bookmarks.update({
      guid: bmk.guid,
      title: "New Title",
    });

    await store.wipe();
    await engine.resetClient();
    // We don't sync via the service, so don't re-hit info/collections, so
    // lastModified remaning at zero breaks things subtly...
    engine.lastModified = null;

    ping = await sync_engine_and_validate_telem(engine, false);
    equal(ping.engines.length, 1);
    equal(ping.engines[0].name, "bookmarks-buffered");
    equal(ping.engines[0].outgoing.length, 1);
    ok(!!ping.engines[0].incoming);
  } finally {
    // Clean up.
    await store.wipe();
    await cleanAndGo(engine, server);
  }
});

add_task(async function test_upload_failed() {
  let collection = new ServerCollection();
  collection._wbos.flying = new ServerWBO("flying");

  let server = sync_httpd_setup({
    "/1.1/foo/storage/rotary": collection.handler(),
  });

  await SyncTestingInfrastructure(server);
  await configureIdentity({ username: "foo" }, server);

  let engine = new RotaryEngine(Service);
  engine._store.items = {
    flying: "LNER Class A3 4472",
    scotsman: "Flying Scotsman",
    peppercorn: "Peppercorn Class",
  };
  const FLYING_CHANGED = 12345;
  const SCOTSMAN_CHANGED = 23456;
  const PEPPERCORN_CHANGED = 34567;
  await engine._tracker.addChangedID("flying", FLYING_CHANGED);
  await engine._tracker.addChangedID("scotsman", SCOTSMAN_CHANGED);
  await engine._tracker.addChangedID("peppercorn", PEPPERCORN_CHANGED);

  let syncID = await engine.resetLocalSyncID();
  let meta_global = Service.recordManager.set(
    engine.metaURL,
    new WBORecord(engine.metaURL)
  );
  meta_global.payload.engines = { rotary: { version: engine.version, syncID } };

  try {
    await engine.setLastSync(123); // needs to be non-zero so that tracker is queried
    let changes = await engine._tracker.getChangedIDs();
    _(
      `test_upload_failed: Rotary tracker contents at first sync: ${JSON.stringify(
        changes
      )}`
    );
    engine.enabled = true;
    let ping = await sync_engine_and_validate_telem(engine, true);
    ok(!!ping);
    equal(ping.engines.length, 1);
    equal(ping.engines[0].incoming, null);
    deepEqual(ping.engines[0].outgoing, [
      {
        sent: 3,
        failed: 2,
        failedReasons: [
          { name: "scotsman", count: 1 },
          { name: "peppercorn", count: 1 },
        ],
      },
    ]);
    await engine.setLastSync(123);

    changes = await engine._tracker.getChangedIDs();
    _(
      `test_upload_failed: Rotary tracker contents at second sync: ${JSON.stringify(
        changes
      )}`
    );
    ping = await sync_engine_and_validate_telem(engine, true);
    ok(!!ping);
    equal(ping.engines.length, 1);
    deepEqual(ping.engines[0].outgoing, [
      {
        sent: 2,
        failed: 2,
        failedReasons: [
          { name: "scotsman", count: 1 },
          { name: "peppercorn", count: 1 },
        ],
      },
    ]);
  } finally {
    await cleanAndGo(engine, server);
    await engine.finalize();
  }
});

add_task(async function test_sync_partialUpload() {
  let collection = new ServerCollection();
  let server = sync_httpd_setup({
    "/1.1/foo/storage/rotary": collection.handler(),
  });
  await SyncTestingInfrastructure(server);
  await generateNewKeys(Service.collectionKeys);

  let engine = new RotaryEngine(Service);
  await engine.setLastSync(123);

  // Create a bunch of records (and server side handlers)
  for (let i = 0; i < 234; i++) {
    let id = "record-no-" + i;
    engine._store.items[id] = "Record No. " + i;
    await engine._tracker.addChangedID(id, i);
    // Let two items in the first upload batch fail.
    if (i != 23 && i != 42) {
      collection.insert(id);
    }
  }

  let syncID = await engine.resetLocalSyncID();
  let meta_global = Service.recordManager.set(
    engine.metaURL,
    new WBORecord(engine.metaURL)
  );
  meta_global.payload.engines = { rotary: { version: engine.version, syncID } };

  try {
    let changes = await engine._tracker.getChangedIDs();
    _(
      `test_sync_partialUpload: Rotary tracker contents at first sync: ${JSON.stringify(
        changes
      )}`
    );
    engine.enabled = true;
    let ping = await sync_engine_and_validate_telem(engine, true);

    ok(!!ping);
    ok(!ping.failureReason);
    equal(ping.engines.length, 1);
    equal(ping.engines[0].name, "rotary");
    ok(!ping.engines[0].incoming);
    ok(!ping.engines[0].failureReason);
    deepEqual(ping.engines[0].outgoing, [
      {
        sent: 234,
        failed: 2,
        failedReasons: [
          { name: "record-no-23", count: 1 },
          { name: "record-no-42", count: 1 },
        ],
      },
    ]);
    collection.post = function () {
      throw new Error("Failure");
    };

    engine._store.items["record-no-1000"] = "Record No. 1000";
    await engine._tracker.addChangedID("record-no-1000", 1000);
    collection.insert("record-no-1000", 1000);

    await engine.setLastSync(123);
    ping = null;

    changes = await engine._tracker.getChangedIDs();
    _(
      `test_sync_partialUpload: Rotary tracker contents at second sync: ${JSON.stringify(
        changes
      )}`
    );
    try {
      // should throw
      await sync_engine_and_validate_telem(
        engine,
        true,
        errPing => (ping = errPing)
      );
    } catch (e) {}
    // It would be nice if we had a more descriptive error for this...
    let uploadFailureError = {
      name: "httperror",
      code: 500,
    };

    ok(!!ping);
    deepEqual(ping.failureReason, uploadFailureError);
    equal(ping.engines.length, 1);
    equal(ping.engines[0].name, "rotary");
    deepEqual(ping.engines[0].incoming, {
      failed: 1,
      failedReasons: [{ name: "No ciphertext: nothing to decrypt?", count: 1 }],
    });
    ok(!ping.engines[0].outgoing);
    deepEqual(ping.engines[0].failureReason, uploadFailureError);
  } finally {
    await cleanAndGo(engine, server);
    await engine.finalize();
  }
});

add_task(async function test_generic_engine_fail() {
  enableValidationPrefs();

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let e = new Error("generic failure message");
  engine._errToThrow = e;

  try {
    const changes = await engine._tracker.getChangedIDs();
    _(
      `test_generic_engine_fail: Steam tracker contents: ${JSON.stringify(
        changes
      )}`
    );
    await sync_and_validate_telem(ping => {
      equal(ping.status.service, SYNC_FAILED_PARTIAL);
      deepEqual(ping.engines.find(err => err.name === "steam").failureReason, {
        name: "unexpectederror",
        error: String(e),
      });
    });
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_engine_fail_weird_errors() {
  enableValidationPrefs();
  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  try {
    let msg = "Bad things happened!";
    engine._errToThrow = { message: msg };
    await sync_and_validate_telem(ping => {
      equal(ping.status.service, SYNC_FAILED_PARTIAL);
      deepEqual(ping.engines.find(err => err.name === "steam").failureReason, {
        name: "unexpectederror",
        error: "Bad things happened!",
      });
    });
    let e = { msg };
    engine._errToThrow = e;
    await sync_and_validate_telem(ping => {
      deepEqual(ping.engines.find(err => err.name === "steam").failureReason, {
        name: "unexpectederror",
        error: JSON.stringify(e),
      });
    });
  } finally {
    await cleanAndGo(engine, server);
    Service.engineManager.unregister(engine);
  }
});

add_task(async function test_overrideTelemetryName() {
  enableValidationPrefs(["steam"]);

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.overrideTelemetryName = "steam-but-better";
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);

  const problemsToReport = [
    { name: "someProblem", count: 123 },
    { name: "anotherProblem", count: 456 },
  ];

  try {
    info("Sync with validation problems");
    engine.problemsToReport = problemsToReport;
    await sync_and_validate_telem(ping => {
      let enginePing = ping.engines.find(e => e.name === "steam-but-better");
      ok(enginePing);
      ok(!ping.engines.find(e => e.name === "steam"));
      deepEqual(
        enginePing.validation,
        {
          version: 1,
          checked: 0,
          problems: problemsToReport,
        },
        "Should include validation report with overridden name"
      );
    });

    info("Sync without validation problems");
    engine.problemsToReport = null;
    await sync_and_validate_telem(ping => {
      let enginePing = ping.engines.find(e => e.name === "steam-but-better");
      ok(enginePing);
      ok(!ping.engines.find(e => e.name === "steam"));
      ok(
        !enginePing.validation,
        "Should not include validation report when there are no problems"
      );
    });
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_engine_fail_ioerror() {
  enableValidationPrefs();

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  // create an IOError to re-throw as part of Sync.
  try {
    // (Note that fakeservices.js has replaced Utils.jsonMove etc, but for
    // this test we need the real one so we get real exceptions from the
    // filesystem.)
    await Utils._real_jsonMove("file-does-not-exist", "anything", {});
  } catch (ex) {
    engine._errToThrow = ex;
  }
  ok(engine._errToThrow, "expecting exception");

  try {
    const changes = await engine._tracker.getChangedIDs();
    _(
      `test_engine_fail_ioerror: Steam tracker contents: ${JSON.stringify(
        changes
      )}`
    );
    await sync_and_validate_telem(ping => {
      equal(ping.status.service, SYNC_FAILED_PARTIAL);
      let failureReason = ping.engines.find(
        e => e.name === "steam"
      ).failureReason;
      equal(failureReason.name, "unexpectederror");
      // ensure the profile dir in the exception message has been stripped.
      ok(
        !failureReason.error.includes(PathUtils.profileDir),
        failureReason.error
      );
      ok(failureReason.error.includes("[profileDir]"), failureReason.error);
    });
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_error_detections() {
  let telem = get_sync_test_telemetry();

  // Non-network NS_ERROR_ codes get their own category.
  Assert.deepEqual(
    telem.transformError(Components.Exception("", Cr.NS_ERROR_FAILURE)),
    { name: "nserror", code: Cr.NS_ERROR_FAILURE }
  );

  // Some NS_ERROR_ code in the "network" module are treated as http errors.
  Assert.deepEqual(
    telem.transformError(Components.Exception("", Cr.NS_ERROR_UNKNOWN_HOST)),
    { name: "httperror", code: Cr.NS_ERROR_UNKNOWN_HOST }
  );
  // Some NS_ERROR_ABORT is treated as network by our telemetry.
  Assert.deepEqual(
    telem.transformError(Components.Exception("", Cr.NS_ERROR_ABORT)),
    { name: "httperror", code: Cr.NS_ERROR_ABORT }
  );
});

add_task(async function test_clean_urls() {
  enableValidationPrefs();

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  engine._errToThrow = new TypeError(
    "http://www.google .com is not a valid URL."
  );

  try {
    const changes = await engine._tracker.getChangedIDs();
    _(`test_clean_urls: Steam tracker contents: ${JSON.stringify(changes)}`);
    await sync_and_validate_telem(ping => {
      equal(ping.status.service, SYNC_FAILED_PARTIAL);
      let failureReason = ping.engines.find(
        e => e.name === "steam"
      ).failureReason;
      equal(failureReason.name, "unexpectederror");
      equal(failureReason.error, "<URL> is not a valid URL.");
    });
    // Handle other errors that include urls.
    engine._errToThrow =
      "Other error message that includes some:url/foo/bar/ in it.";
    await sync_and_validate_telem(ping => {
      equal(ping.status.service, SYNC_FAILED_PARTIAL);
      let failureReason = ping.engines.find(
        e => e.name === "steam"
      ).failureReason;
      equal(failureReason.name, "unexpectederror");
      equal(
        failureReason.error,
        "Other error message that includes <URL> in it."
      );
    });
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

// Test sanitizing guid-related errors with the pattern of <guid: {guid}>
add_task(async function test_sanitize_bookmarks_guid() {
  let { ErrorSanitizer } = ChromeUtils.importESModule(
    "resource://services-sync/telemetry.sys.mjs"
  );

  for (let [original, expected] of [
    [
      "Can't insert Bookmark <guid: sknD84IdnSY2> into Folder <guid: odfninDdi93_3>",
      "Can't insert Bookmark <GUID> into Folder <GUID>",
    ],
    [
      "Merge Error: Item <guid: H6fmPA16gZs9> can't contain itself",
      "Merge Error: Item <GUID> can't contain itself",
    ],
  ]) {
    const sanitized = ErrorSanitizer.cleanErrorMessage(original);
    Assert.equal(sanitized, expected);
  }
});

// Test sanitization of some hard-coded error strings.
add_task(async function test_clean_errors() {
  let { ErrorSanitizer } = ChromeUtils.importESModule(
    "resource://services-sync/telemetry.sys.mjs"
  );

  for (let [original, expected] of [
    [
      "Win error 112 during operation write on file [profileDir]\\weave\\addonsreconciler.json (Espacio en disco insuficiente. )",
      "OS error [No space left on device] during operation write on file [profileDir]/weave/addonsreconciler.json",
    ],
    [
      "Unix error 28 during operation write on file [profileDir]/weave/addonsreconciler.json (No space left on device)",
      "OS error [No space left on device] during operation write on file [profileDir]/weave/addonsreconciler.json",
    ],
  ]) {
    const sanitized = ErrorSanitizer.cleanErrorMessage(original);
    Assert.equal(sanitized, expected);
  }
});

// Arrange for a sync to hit a "real" OS error during a sync and make sure it's sanitized.
add_task(async function test_clean_real_os_error() {
  enableValidationPrefs();

  // Simulate a real error.
  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let path = PathUtils.join(PathUtils.profileDir, "no", "such", "path.json");
  try {
    await IOUtils.readJSON(path);
    throw new Error("should fail to read the file");
  } catch (ex) {
    engine._errToThrow = ex;
  }

  try {
    const changes = await engine._tracker.getChangedIDs();
    _(`test_clean_urls: Steam tracker contents: ${JSON.stringify(changes)}`);
    await sync_and_validate_telem(ping => {
      equal(ping.status.service, SYNC_FAILED_PARTIAL);
      let failureReason = ping.engines.find(
        e => e.name === "steam"
      ).failureReason;
      equal(failureReason.name, "unexpectederror");
      equal(
        failureReason.error,
        "OS error [File/Path not found] Could not open the file at [profileDir]/no/such/path.json"
      );
    });
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_initial_sync_engines() {
  enableValidationPrefs();

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  // These are the only ones who actually have things to sync at startup.
  let telemetryEngineNames = ["clients", "prefs", "tabs", "bookmarks-buffered"];
  let server = await serverForEnginesWithKeys(
    { foo: "password" },
    ["bookmarks", "prefs", "tabs"].map(name => Service.engineManager.get(name))
  );
  await SyncTestingInfrastructure(server);
  try {
    const changes = await engine._tracker.getChangedIDs();
    _(
      `test_initial_sync_engines: Steam tracker contents: ${JSON.stringify(
        changes
      )}`
    );
    let ping = await wait_for_ping(() => Service.sync(), true);

    equal(ping.engines.find(e => e.name === "clients").outgoing[0].sent, 1);
    equal(ping.engines.find(e => e.name === "tabs").outgoing[0].sent, 1);

    // for the rest we don't care about specifics
    for (let e of ping.engines) {
      if (!telemetryEngineNames.includes(engine.name)) {
        continue;
      }
      greaterOrEqual(e.took, 1);
      ok(!!e.outgoing);
      equal(e.outgoing.length, 1);
      notEqual(e.outgoing[0].sent, undefined);
      equal(e.outgoing[0].failed, undefined);
      equal(e.outgoing[0].failedReasons, undefined);
    }
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_nserror() {
  enableValidationPrefs();

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  engine._errToThrow = Components.Exception(
    "NS_ERROR_UNKNOWN_HOST",
    Cr.NS_ERROR_UNKNOWN_HOST
  );
  try {
    const changes = await engine._tracker.getChangedIDs();
    _(`test_nserror: Steam tracker contents: ${JSON.stringify(changes)}`);
    await sync_and_validate_telem(ping => {
      deepEqual(ping.status, {
        service: SYNC_FAILED_PARTIAL,
        sync: LOGIN_FAILED_NETWORK_ERROR,
      });
      let enginePing = ping.engines.find(e => e.name === "steam");
      deepEqual(enginePing.failureReason, {
        name: "httperror",
        code: Cr.NS_ERROR_UNKNOWN_HOST,
      });
    });
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_sync_why() {
  enableValidationPrefs();

  await Service.engineManager.register(SteamEngine);
  let engine = Service.engineManager.get("steam");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  await SyncTestingInfrastructure(server);
  let e = new Error("generic failure message");
  engine._errToThrow = e;

  try {
    const changes = await engine._tracker.getChangedIDs();
    _(
      `test_generic_engine_fail: Steam tracker contents: ${JSON.stringify(
        changes
      )}`
    );
    let ping = await wait_for_ping(
      () => Service.sync({ why: "user" }),
      true,
      false
    );
    _(JSON.stringify(ping));
    equal(ping.why, "user");
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_discarding() {
  enableValidationPrefs();

  let helper = track_collections_helper();
  let upd = helper.with_updated_collection;
  let telem = get_sync_test_telemetry();
  telem.maxPayloadCount = 2;
  telem.submissionInterval = Infinity;
  let oldSubmit = telem.submit;

  let server;
  try {
    let handlers = {
      "/1.1/johndoe/info/collections": helper.handler,
      "/1.1/johndoe/storage/crypto/keys": upd(
        "crypto",
        new ServerWBO("keys").handler()
      ),
      "/1.1/johndoe/storage/meta/global": upd(
        "meta",
        new ServerWBO("global").handler()
      ),
    };

    let collections = [
      "clients",
      "bookmarks",
      "forms",
      "history",
      "passwords",
      "prefs",
      "tabs",
    ];

    for (let coll of collections) {
      handlers["/1.1/johndoe/storage/" + coll] = upd(
        coll,
        new ServerCollection({}, true).handler()
      );
    }

    server = httpd_setup(handlers);
    await configureIdentity({ username: "johndoe" }, server);
    telem.submit = p =>
      ok(
        false,
        "Submitted telemetry ping when we should not have" + JSON.stringify(p)
      );

    for (let i = 0; i < 5; ++i) {
      await Service.sync();
    }
    telem.submit = oldSubmit;
    telem.submissionInterval = -1;
    let ping = await wait_for_ping(() => Service.sync(), true, true); // with this we've synced 6 times
    equal(ping.syncs.length, 2);
    equal(ping.discarded, 4);
  } finally {
    telem.maxPayloadCount = 500;
    telem.submissionInterval = -1;
    telem.submit = oldSubmit;
    if (server) {
      await promiseStopServer(server);
    }
  }
});

add_task(async function test_submit_interval() {
  let telem = get_sync_test_telemetry();
  let oldSubmit = telem.submit;
  let numSubmissions = 0;
  telem.submit = function () {
    numSubmissions += 1;
  };

  function notify(what, data = null) {
    Svc.Obs.notify(what, JSON.stringify(data));
  }

  try {
    // submissionInterval is set such that each sync should submit
    notify("weave:service:sync:start", { why: "testing" });
    notify("weave:service:sync:finish");
    Assert.equal(numSubmissions, 1, "should submit this ping due to interval");

    // As should each event outside of a sync.
    Service.recordTelemetryEvent("object", "method");
    Assert.equal(numSubmissions, 2);

    // But events while we are syncing should not.
    notify("weave:service:sync:start", { why: "testing" });
    Service.recordTelemetryEvent("object", "method");
    Assert.equal(numSubmissions, 2, "no submission for this event");
    notify("weave:service:sync:finish");
    Assert.equal(numSubmissions, 3, "was submitted after sync finish");
  } finally {
    telem.submit = oldSubmit;
  }
});

add_task(async function test_no_foreign_engines_in_error_ping() {
  enableValidationPrefs();

  await Service.engineManager.register(BogusEngine);
  let engine = Service.engineManager.get("bogus");
  engine.enabled = true;
  let server = await serverForFoo(engine);
  engine._errToThrow = new Error("Oh no!");
  await SyncTestingInfrastructure(server);
  try {
    await sync_and_validate_telem(ping => {
      equal(ping.status.service, SYNC_FAILED_PARTIAL);
      ok(ping.engines.every(e => e.name !== "bogus"));
    });
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_no_foreign_engines_in_success_ping() {
  enableValidationPrefs();

  await Service.engineManager.register(BogusEngine);
  let engine = Service.engineManager.get("bogus");
  engine.enabled = true;
  let server = await serverForFoo(engine);

  await SyncTestingInfrastructure(server);
  try {
    await sync_and_validate_telem(ping => {
      ok(ping.engines.every(e => e.name !== "bogus"));
    });
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_events() {
  enableValidationPrefs();

  await Service.engineManager.register(BogusEngine);
  let engine = Service.engineManager.get("bogus");
  engine.enabled = true;
  let server = await serverForFoo(engine);

  await SyncTestingInfrastructure(server);

  let telem = get_sync_test_telemetry();
  telem.submissionInterval = Infinity;

  try {
    let serverTime = Resource.serverTime;
    Service.recordTelemetryEvent("object", "method", "value", { foo: "bar" });
    let ping = await wait_for_ping(() => Service.sync(), true, true);
    equal(ping.events.length, 1);
    let [timestamp, category, method, object, value, extra] = ping.events[0];
    ok(typeof timestamp == "number" && timestamp > 0); // timestamp.
    equal(category, "sync");
    equal(method, "method");
    equal(object, "object");
    equal(value, "value");
    deepEqual(extra, { foo: "bar", serverTime: String(serverTime) });
    ping = await wait_for_ping(
      () => {
        // Test with optional values.
        Service.recordTelemetryEvent("object", "method");
      },
      false,
      true
    );
    equal(ping.events.length, 1);
    equal(ping.events[0].length, 4);

    ping = await wait_for_ping(
      () => {
        Service.recordTelemetryEvent("object", "method", "extra");
      },
      false,
      true
    );
    equal(ping.events.length, 1);
    equal(ping.events[0].length, 5);

    ping = await wait_for_ping(
      () => {
        Service.recordTelemetryEvent("object", "method", undefined, {
          foo: "bar",
        });
      },
      false,
      true
    );
    equal(ping.events.length, 1);
    equal(ping.events[0].length, 6);
    [timestamp, category, method, object, value, extra] = ping.events[0];
    equal(value, null);

    // Fake a submission due to shutdown.
    ping = await wait_for_ping(
      () => {
        telem.submissionInterval = Infinity;
        Service.recordTelemetryEvent("object", "method", undefined, {
          foo: "bar",
        });
        telem.finish("shutdown");
      },
      false,
      true
    );
    equal(ping.syncs.length, 0);
    equal(ping.events.length, 1);
    equal(ping.events[0].length, 6);
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_histograms() {
  enableValidationPrefs();

  await Service.engineManager.register(BogusEngine);
  let engine = Service.engineManager.get("bogus");
  engine.enabled = true;
  let server = await serverForFoo(engine);

  await SyncTestingInfrastructure(server);
  try {
    let histId = "TELEMETRY_TEST_LINEAR";
    Services.obs.notifyObservers(null, "weave:telemetry:histogram", histId);
    let ping = await wait_for_ping(() => Service.sync(), true, true);
    equal(Object.keys(ping.histograms).length, 1);
    equal(ping.histograms[histId].sum, 0);
    equal(ping.histograms[histId].histogram_type, 1);
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_invalid_events() {
  enableValidationPrefs();

  await Service.engineManager.register(BogusEngine);
  let engine = Service.engineManager.get("bogus");
  engine.enabled = true;
  let server = await serverForFoo(engine);

  async function checkNotRecorded(...args) {
    Service.recordTelemetryEvent.call(args);
    let ping = await wait_for_ping(() => Service.sync(), false, true);
    equal(ping.events, undefined);
  }

  await SyncTestingInfrastructure(server);
  try {
    let long21 = "l".repeat(21);
    let long81 = "l".repeat(81);
    let long86 = "l".repeat(86);
    await checkNotRecorded("object");
    await checkNotRecorded("object", 2);
    await checkNotRecorded(2, "method");
    await checkNotRecorded("object", "method", 2);
    await checkNotRecorded("object", "method", "value", 2);
    await checkNotRecorded("object", "method", "value", { foo: 2 });
    await checkNotRecorded(long21, "method", "value");
    await checkNotRecorded("object", long21, "value");
    await checkNotRecorded("object", "method", long81);
    let badextra = {};
    badextra[long21] = "x";
    await checkNotRecorded("object", "method", "value", badextra);
    badextra = { x: long86 };
    await checkNotRecorded("object", "method", "value", badextra);
    for (let i = 0; i < 10; i++) {
      badextra["name" + i] = "x";
    }
    await checkNotRecorded("object", "method", "value", badextra);
  } finally {
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_no_ping_for_self_hosters() {
  enableValidationPrefs();

  let telem = get_sync_test_telemetry();
  let oldSubmit = telem.submit;

  await Service.engineManager.register(BogusEngine);
  let engine = Service.engineManager.get("bogus");
  engine.enabled = true;
  let server = await serverForFoo(engine);

  await SyncTestingInfrastructure(server);
  try {
    let submitPromise = new Promise(resolve => {
      telem.submit = function () {
        let result = oldSubmit.apply(this, arguments);
        resolve(result);
      };
    });
    await Service.sync();
    let pingSubmitted = await submitPromise;
    // The Sync testing infrastructure already sets up a custom token server,
    // so we don't need to do anything to simulate a self-hosted user.
    ok(!pingSubmitted, "Should not submit ping with custom token server URL");
  } finally {
    telem.submit = oldSubmit;
    await cleanAndGo(engine, server);
    await Service.engineManager.unregister(engine);
  }
});

add_task(async function test_fxa_device_telem() {
  let t = get_sync_test_telemetry();
  let syncEnabled = true;
  let oldGetClientsEngineRecords = t.getClientsEngineRecords;
  let oldGetFxaDevices = t.getFxaDevices;
  let oldSyncIsEnabled = t.syncIsEnabled;
  let oldSanitizeFxaDeviceId = t.sanitizeFxaDeviceId;
  t.syncIsEnabled = () => syncEnabled;
  t.sanitizeFxaDeviceId = id => `So clean: ${id}`;
  try {
    let keep0 = Utils.makeGUID();
    let keep1 = Utils.makeGUID();
    let keep2 = Utils.makeGUID();
    let curdev = Utils.makeGUID();

    let keep1Sync = Utils.makeGUID();
    let keep2Sync = Utils.makeGUID();
    let curdevSync = Utils.makeGUID();
    let fxaDevices = [
      {
        id: curdev,
        isCurrentDevice: true,
        lastAccessTime: Date.now() - 1000 * 60 * 60 * 24 * 1,
        pushEndpointExpired: false,
        type: "desktop",
        name: "current device",
      },
      {
        id: keep0,
        isCurrentDevice: false,
        lastAccessTime: Date.now() - 1000 * 60 * 60 * 24 * 10,
        pushEndpointExpired: false,
        type: "mobile",
        name: "dupe",
      },
      // Valid 2
      {
        id: keep1,
        isCurrentDevice: false,
        lastAccessTime: Date.now() - 1000 * 60 * 60 * 24 * 1,
        pushEndpointExpired: false,
        type: "desktop",
        name: "valid2",
      },
      // Valid 3
      {
        id: keep2,
        isCurrentDevice: false,
        lastAccessTime: Date.now() - 1000 * 60 * 60 * 24 * 5,
        pushEndpointExpired: false,
        type: "desktop",
        name: "valid3",
      },
    ];
    let clientInfo = [
      {
        id: keep1Sync,
        fxaDeviceId: keep1,
        os: "Windows 30",
        version: "Firefox 1 million",
      },
      {
        id: keep2Sync,
        fxaDeviceId: keep2,
        os: "firefox, but an os",
        verison: "twelve",
      },
      {
        id: Utils.makeGUID(),
        fxaDeviceId: null,
        os: "apparently ios used to keep write these IDs as null.",
        version: "Doesn't seem to anymore",
      },
      {
        id: curdevSync,
        fxaDeviceId: curdev,
        os: "emacs",
        version: "22",
      },
      {
        id: Utils.makeGUID(),
        fxaDeviceId: Utils.makeGUID(),
        os: "not part of the fxa device set at all",
        version: "foo bar baz",
      },
      // keep0 intententionally omitted.
    ];
    t.getClientsEngineRecords = () => clientInfo;
    let devInfo = t.updateFxaDevices(fxaDevices);
    equal(devInfo.deviceID, t.sanitizeFxaDeviceId(curdev));
    for (let d of devInfo.devices) {
      ok(d.id.startsWith("So clean:"));
      if (d.syncID) {
        ok(d.syncID.startsWith("So clean:"));
      }
    }
    equal(devInfo.devices.length, 4);
    let k0 = devInfo.devices.find(d => d.id == t.sanitizeFxaDeviceId(keep0));
    let k1 = devInfo.devices.find(d => d.id == t.sanitizeFxaDeviceId(keep1));
    let k2 = devInfo.devices.find(d => d.id == t.sanitizeFxaDeviceId(keep2));

    deepEqual(k0, {
      id: t.sanitizeFxaDeviceId(keep0),
      type: "mobile",
      os: undefined,
      version: undefined,
      syncID: undefined,
    });
    deepEqual(k1, {
      id: t.sanitizeFxaDeviceId(keep1),
      type: "desktop",
      os: clientInfo[0].os,
      version: clientInfo[0].version,
      syncID: t.sanitizeFxaDeviceId(keep1Sync),
    });
    deepEqual(k2, {
      id: t.sanitizeFxaDeviceId(keep2),
      type: "desktop",
      os: clientInfo[1].os,
      version: clientInfo[1].version,
      syncID: t.sanitizeFxaDeviceId(keep2Sync),
    });
    let newCurId = Utils.makeGUID();
    // Update the ID
    fxaDevices[0].id = newCurId;

    let keep3 = Utils.makeGUID();
    fxaDevices.push({
      id: keep3,
      isCurrentDevice: false,
      lastAccessTime: Date.now() - 1000 * 60 * 60 * 24 * 1,
      pushEndpointExpired: false,
      type: "desktop",
      name: "valid 4",
    });
    devInfo = t.updateFxaDevices(fxaDevices);

    let afterSubmit = [keep0, keep1, keep2, keep3, newCurId]
      .map(id => t.sanitizeFxaDeviceId(id))
      .sort();
    deepEqual(devInfo.devices.map(d => d.id).sort(), afterSubmit);

    // Reset this, as our override doesn't check for sync being enabled.
    t.sanitizeFxaDeviceId = oldSanitizeFxaDeviceId;
    syncEnabled = false;
    fxAccounts.telemetry._setHashedUID(false);
    devInfo = t.updateFxaDevices(fxaDevices);
    equal(devInfo.deviceID, undefined);
    equal(devInfo.devices.length, 5);
    for (let d of devInfo.devices) {
      equal(d.os, undefined);
      equal(d.version, undefined);
      equal(d.syncID, undefined);
      // Type should still be present.
      notEqual(d.type, undefined);
    }
  } finally {
    t.getClientsEngineRecords = oldGetClientsEngineRecords;
    t.getFxaDevices = oldGetFxaDevices;
    t.syncIsEnabled = oldSyncIsEnabled;
    t.sanitizeFxaDeviceId = oldSanitizeFxaDeviceId;
  }
});

add_task(async function test_sanitize_fxa_device_id() {
  let t = get_sync_test_telemetry();
  fxAccounts.telemetry._setHashedUID(false);
  sinon.stub(t, "syncIsEnabled").callsFake(() => true);
  const rawDeviceId = "raw one two three";
  try {
    equal(t.sanitizeFxaDeviceId(rawDeviceId), null);
    fxAccounts.telemetry._setHashedUID("mock uid");
    const sanitizedDeviceId = t.sanitizeFxaDeviceId(rawDeviceId);
    ok(sanitizedDeviceId);
    notEqual(sanitizedDeviceId, rawDeviceId);
  } finally {
    t.syncIsEnabled.restore();
    fxAccounts.telemetry._setHashedUID(false);
  }
});

add_task(async function test_no_node_type() {
  let server = sync_httpd_setup({});
  await configureIdentity(null, server);

  await sync_and_validate_telem(ping => {
    ok(ping.syncNodeType === undefined);
  }, true);
  await promiseStopServer(server);
});

add_task(async function test_node_type() {
  Service.identity.logout();
  let server = sync_httpd_setup({});
  await configureIdentity({ node_type: "the-node-type" }, server);

  await sync_and_validate_telem(ping => {
    equal(ping.syncNodeType, "the-node-type");
  }, true);
  await promiseStopServer(server);
});

add_task(async function test_node_type_change() {
  let pingPromise = wait_for_pings(2);

  Service.identity.logout();
  let server = sync_httpd_setup({});
  await configureIdentity({ node_type: "first-node-type" }, server);
  // Default to submitting each hour - we should still submit on node change.
  let telem = get_sync_test_telemetry();
  telem.submissionInterval = 60 * 60 * 1000;
  // reset the node type from previous test or our first sync will submit.
  telem.lastSyncNodeType = null;
  // do 2 syncs with the same node type.
  await Service.sync();
  await Service.sync();
  // then another with a different node type.
  Service.identity.logout();
  await configureIdentity({ node_type: "second-node-type" }, server);
  await Service.sync();
  telem.finish();

  let pings = await pingPromise;
  equal(pings.length, 2);
  equal(pings[0].syncs.length, 2, "2 syncs in first ping");
  equal(pings[0].syncNodeType, "first-node-type");
  equal(pings[1].syncs.length, 1, "1 sync in second ping");
  equal(pings[1].syncNodeType, "second-node-type");
  await promiseStopServer(server);
});

add_task(async function test_ids() {
  let telem = get_sync_test_telemetry();
  Assert.ok(!telem._shouldSubmitForDataChange());
  fxAccounts.telemetry._setHashedUID("new_uid");
  Assert.ok(telem._shouldSubmitForDataChange());
  telem.maybeSubmitForDataChange();
  // now it's been submitted the new uid is current.
  Assert.ok(!telem._shouldSubmitForDataChange());
});

add_task(async function test_deletion_request_ping() {
  async function assertRecordedSyncDeviceID(expected) {
    // The scalar gets updated asynchronously, so wait a tick before checking.
    await Promise.resolve();
    const scalars =
      Services.telemetry.getSnapshotForScalars("deletion-request").parent || {};
    equal(scalars["deletion.request.sync_device_id"], expected);
  }

  const MOCK_HASHED_UID = "00112233445566778899aabbccddeeff";
  const MOCK_DEVICE_ID1 = "ffeeddccbbaa99887766554433221100";
  const MOCK_DEVICE_ID2 = "aabbccddeeff99887766554433221100";

  // Calculated by hand using SHA256(DEVICE_ID + HASHED_UID)[:32]
  const SANITIZED_DEVICE_ID1 = "dd7c845006df9baa1c6d756926519c8c";
  const SANITIZED_DEVICE_ID2 = "0d06919a736fc029007e1786a091882c";

  let currentDeviceID = null;
  sinon.stub(fxAccounts.device, "getLocalId").callsFake(() => {
    return Promise.resolve(currentDeviceID);
  });
  let telem = get_sync_test_telemetry();
  sinon.stub(telem, "isProductionSyncUser").callsFake(() => true);
  fxAccounts.telemetry._setHashedUID(false);
  try {
    // The scalar should start out undefined, since no user is actually logged in.
    await assertRecordedSyncDeviceID(undefined);

    // If we start up without knowing the hashed UID, it should stay undefined.
    telem.observe(null, "weave:service:ready");
    await assertRecordedSyncDeviceID(undefined);

    // But now let's say we've discovered the hashed UID from the server.
    fxAccounts.telemetry._setHashedUID(MOCK_HASHED_UID);
    currentDeviceID = MOCK_DEVICE_ID1;

    // Now when we load up, we'll record the sync device id.
    telem.observe(null, "weave:service:ready");
    await assertRecordedSyncDeviceID(SANITIZED_DEVICE_ID1);

    // When the device-id changes we'll update it.
    currentDeviceID = MOCK_DEVICE_ID2;
    telem.observe(null, "fxaccounts:new_device_id");
    await assertRecordedSyncDeviceID(SANITIZED_DEVICE_ID2);

    // When the user signs out we'll clear it.
    telem.observe(null, "fxaccounts:onlogout");
    await assertRecordedSyncDeviceID("");
  } finally {
    fxAccounts.telemetry._setHashedUID(false);
    telem.isProductionSyncUser.restore();
    fxAccounts.device.getLocalId.restore();
  }
});
