/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const TEST_URL_PATH =
  "/browser/browser/components/originattributes/test/browser/";

// The flags of test modes.
const TEST_MODE_FIRSTPARTY = 0;
const TEST_MODE_NO_ISOLATION = 1;
const TEST_MODE_CONTAINERS = 2;

// The name of each mode.
const TEST_MODE_NAMES = ["first party isolation", "no isolation", "containers"];

// The frame types.
const TEST_TYPE_FRAME = 1;
const TEST_TYPE_IFRAME = 2;

// The default frame setting.
const DEFAULT_FRAME_SETTING = [TEST_TYPE_IFRAME];

let gFirstPartyBasicPage = TEST_URL_PATH + "file_firstPartyBasic.html";

/**
 * Add a tab for the given url with the specific user context id.
 *
 * @param aURL
 *    The url of the page.
 * @param aUserContextId
 *    The user context id for this tab.
 *
 * @return tab     - The tab object of this tab.
 *         browser - The browser object of this tab.
 */
async function openTabInUserContext(aURL, aUserContextId) {
  info(`Start to open tab in specific userContextID: ${aUserContextId}.`);
  let originAttributes = {
    userContextId: aUserContextId,
  };
  info("Create triggeringPrincipal.");
  let triggeringPrincipal = Services.scriptSecurityManager.createContentPrincipal(
    makeURI(aURL),
    originAttributes
  );
  // Open the tab in the correct userContextId.
  info("Open the tab and wait for it to be loaded.");
  let tab = BrowserTestUtils.addTab(gBrowser, aURL, {
    userContextId: aUserContextId,
    triggeringPrincipal,
  });

  // Select tab and make sure its browser is focused.
  gBrowser.selectedTab = tab;
  tab.ownerGlobal.focus();

  let browser = gBrowser.getBrowserForTab(tab);
  await BrowserTestUtils.browserLoaded(browser);
  info("Finished tab opening.");

  return { tab, browser };
}

/**
 * Add a tab for a page with the given first party domain. This page will have
 * an iframe which is loaded with the given url by default or you could specify
 * a frame setting to create nested frames. And this function will also modify
 * the 'content' in the ContentTask to the target frame's window object.
 *
 * @param aURL
 *    The url of the iframe.
 * @param aFirstPartyDomain
 *    The first party domain.
 * @param aFrameSetting
 *    This setting controls how frames are organized within the page. The
 *    setting is an array of frame types, the first item indicates the
 *    frame type (iframe or frame) of the first layer of the frame structure,
 *    and the second item indicates the second layer, and so on. The aURL will
 *    be loaded at the deepest layer. This is optional.
 *
 * @return tab     - The tab object of this tab.
 *         browser - The browser object of this tab.
 */
async function openTabInFirstParty(
  aURL,
  aFirstPartyDomain,
  aFrameSetting = DEFAULT_FRAME_SETTING
) {
  info(`Start to open tab under first party domain "${aFirstPartyDomain}".`);
  // If the first party domain ends with '/', we remove it.
  if (aFirstPartyDomain.endsWith("/")) {
    aFirstPartyDomain = aFirstPartyDomain.slice(0, -1);
  }

  let basicPageURL = aFirstPartyDomain + gFirstPartyBasicPage;

  // Open the tab for the basic first party page.
  info("Open the tab and then wait for it to be loaded.");
  let tab = BrowserTestUtils.addTab(gBrowser, basicPageURL);

  // Select tab and make sure its browser is focused.
  gBrowser.selectedTab = tab;
  tab.ownerGlobal.focus();

  let browser = gBrowser.getBrowserForTab(tab);
  await BrowserTestUtils.browserLoaded(browser);

  // Clone the frame setting here since we will modify it later.
  let frameSetting = aFrameSetting.slice(0);
  let frameType;
  let targetBrowsingContext;

  // Create the frame structure.
  info("Create the frame structure.");
  while ((frameType = frameSetting.shift())) {
    if (!targetBrowsingContext) {
      targetBrowsingContext = browser;
    }

    let frameURL = !frameSetting.length ? aURL : basicPageURL;

    if (frameType == TEST_TYPE_FRAME) {
      info("Add the frameset.");
      targetBrowsingContext = await SpecialPowers.spawn(
        targetBrowsingContext,
        [frameURL],
        async function(aFrameURL) {
          // Add a frameset which carries the frame element.
          let frameSet = content.document.createElement("frameset");
          frameSet.cols = "50%,50%";

          let frame = content.document.createElement("frame");
          let dummyFrame = content.document.createElement("frame");

          frameSet.appendChild(frame);
          frameSet.appendChild(dummyFrame);

          content.document.body.appendChild(frameSet);

          // Wait for the frame to be loaded.
          await new Promise(done => {
            frame.addEventListener(
              "load",
              function() {
                done();
              },
              { capture: true, once: true }
            );

            frame.setAttribute("src", aFrameURL);
          });

          return frame.browsingContext;
        }
      );
    } else if (frameType == TEST_TYPE_IFRAME) {
      info("Add the iframe.");
      targetBrowsingContext = await SpecialPowers.spawn(
        targetBrowsingContext,
        [frameURL],
        async function(aFrameURL) {
          // Add an iframe.
          let frame = content.document.createElement("iframe");
          content.document.body.appendChild(frame);

          // Wait for the frame to be loaded.
          await new Promise(done => {
            frame.addEventListener(
              "load",
              function() {
                done();
              },
              { capture: true, once: true }
            );

            frame.setAttribute("src", aFrameURL);
          });

          return frame.browsingContext;
        }
      );
    } else {
      ok(false, "Invalid frame type.");
      break;
    }
    info("Successfully added a frame");
  }
  info("Finished the frame structure");

  return { tab, browser: targetBrowsingContext };
}

this.IsolationTestTools = {
  /**
   * Adds isolation tests for first party isolation, no isolation
   * and containers respectively.
   *
   * @param aTask
   *    The testing task which will be run in different settings.
   */
  _add_task(aTask) {
    let testSettings = [
      {
        mode: TEST_MODE_FIRSTPARTY,
        skip: false,
        prefs: [["privacy.firstparty.isolate", true]],
      },
      {
        mode: TEST_MODE_NO_ISOLATION,
        skip: false,
        prefs: [["privacy.firstparty.isolate", false]],
      },
      {
        mode: TEST_MODE_CONTAINERS,
        skip: false,
        prefs: [["privacy.userContext.enabled", true]],
      },
    ];

    // Add test tasks.
    for (let testSetting of testSettings) {
      IsolationTestTools._addTaskForMode(
        testSetting.mode,
        testSetting.prefs,
        testSetting.skip,
        aTask
      );
    }
  },

  _addTaskForMode(aMode, aPref, aSkip, aTask) {
    if (aSkip) {
      return;
    }

    add_task(async function() {
      info(`Starting the test for ${TEST_MODE_NAMES[aMode]}.`);

      // Before run this task, reset the preferences first.
      await SpecialPowers.flushPrefEnv();

      // Make sure preferences are set properly.
      await SpecialPowers.pushPrefEnv({ set: aPref });

      await SpecialPowers.pushPrefEnv({ set: [["dom.ipc.processCount", 1]] });
      await SpecialPowers.pushPrefEnv({
        set: [
          [
            "network.auth.non-web-content-triggered-resources-http-auth-allow",
            true,
          ],
        ],
      });

      await aTask(aMode);
    });
  },

  /**
   * Add a tab with the given tab setting, this will open different types of
   * tabs according to the given test mode. A tab setting means a isolation
   * target in different test mode; a tab setting indicates a first party
   * domain when testing the first party isolation, it is a user context
   * id when testing containers.
   *
   * @param aMode
   *    The test mode which decides what type of tabs will be opened.
   * @param aURL
   *    The url which is going to open.
   * @param aTabSettingObj
   *    The tab setting object includes 'firstPartyDomain' for the first party
   *    domain and 'userContextId' for Containers.
   * @param aFrameSetting
   *    This setting controls how frames are organized within the page. The
   *    setting is an array of frame types, the first item indicates the
   *    frame type (iframe or frame) of the first layer of the frame structure,
   *    and the second item indicates the second layer, and so on. The aURL
   *    will be loaded at the deepest layer. This is optional.
   *
   * @return tab     - The tab object of this tab.
   *         browser - The browser object of this tab.
   */
  _addTab(aMode, aURL, aTabSettingObj, aFrameSetting) {
    if (aMode === TEST_MODE_CONTAINERS) {
      return openTabInUserContext(aURL, aTabSettingObj.userContextId);
    }

    return openTabInFirstParty(
      aURL,
      aTabSettingObj.firstPartyDomain,
      aFrameSetting
    );
  },

  /**
   * Run isolation tests. The framework will run tests with standard combinations
   * of prefs and tab settings, and checks whether the isolation is working.
   *
   * @param aURL
   *    The URL of the page that will be tested or an object contains 'url',
   *    the tested page, 'firstFrameSetting' for the frame setting of the first
   *    tab, and 'secondFrameSetting' for the second tab.
   * @param aGetResultFuncs
   *    An array of functions or a single function which are responsible for
   *    returning the isolation result back to the framework for further checking.
   *    Each of these functions will be provided the browser object of the tab,
   *    that allows modifying or fetchings results from the page content.
   * @param aCompareResultFunc
   *    An optional function which allows modifying the way how does framework
   *    check results. This function will be provided a boolean to indicate
   *    the isolation is no or off and two results. This function should return
   *    a boolean to tell that whether isolation is working. If this function
   *    is not given, the framework will take case checking by itself.
   * @param aBeforeFunc
   *    An optional function which is called before any tabs are created so
   *    that the test case can set up/reset local state.
   * @param aGetResultImmediately
   *    An optional boolean to ensure we get results before the next tab is opened.
   */
  runTests(
    aURL,
    aGetResultFuncs,
    aCompareResultFunc,
    aBeforeFunc,
    aGetResultImmediately,
    aUseHttps
  ) {
    let pageURL;
    let firstFrameSetting;
    let secondFrameSetting;

    // Request a longer timeout since the test will run a test for three times
    // with different settings. Thus, one test here represents three tests.
    // For this reason, we triple the timeout.
    requestLongerTimeout(3);

    if (typeof aURL === "string") {
      pageURL = aURL;
    } else if (typeof aURL === "object") {
      pageURL = aURL.url;
      firstFrameSetting = aURL.firstFrameSetting;
      secondFrameSetting = aURL.secondFrameSetting;
    }

    if (!Array.isArray(aGetResultFuncs)) {
      aGetResultFuncs = [aGetResultFuncs];
    }

    let tabSettings = aUseHttps
      ? [
          { firstPartyDomain: "https://example.com", userContextId: 1 },
          { firstPartyDomain: "https://example.org", userContextId: 2 },
        ]
      : [
          { firstPartyDomain: "http://example.com", userContextId: 1 },
          { firstPartyDomain: "http://example.org", userContextId: 2 },
        ];

    this._add_task(async function(aMode) {
      let tabSettingA = 0;

      for (let tabSettingB of [0, 1]) {
        // Give the test a chance to set up before each case is run.
        if (aBeforeFunc) {
          try {
            await aBeforeFunc(aMode);
          } catch (e) {
            ok(false, `Caught error while doing testing setup: ${e}.`);
          }
        }
        // Create Tabs.
        info(`Create tab A for ${TEST_MODE_NAMES[aMode]} test.`);
        let tabInfoA = await IsolationTestTools._addTab(
          aMode,
          pageURL,
          tabSettings[tabSettingA],
          firstFrameSetting
        );
        info(`Finished Create tab A for ${TEST_MODE_NAMES[aMode]} test.`);
        let resultsA = [];
        if (aGetResultImmediately) {
          try {
            info(
              `Immediately get result from tab A for ${TEST_MODE_NAMES[aMode]} test`
            );
            for (let getResultFunc of aGetResultFuncs) {
              resultsA.push(await getResultFunc(tabInfoA.browser));
            }
          } catch (e) {
            ok(false, `Caught error while getting result from Tab A: ${e}.`);
          }
        }
        info(`Create tab B for ${TEST_MODE_NAMES[aMode]}.`);
        let tabInfoB = await IsolationTestTools._addTab(
          aMode,
          pageURL,
          tabSettings[tabSettingB],
          secondFrameSetting
        );
        info(`Finished Create tab B for ${TEST_MODE_NAMES[aMode]} test.`);
        let i = 0;
        for (let getResultFunc of aGetResultFuncs) {
          // Fetch results from tabs.
          info(`Fetching result from tab A for ${TEST_MODE_NAMES[aMode]}.`);
          let resultA;
          try {
            resultA = aGetResultImmediately
              ? resultsA[i++]
              : await getResultFunc(tabInfoA.browser);
          } catch (e) {
            ok(false, `Caught error while getting result from Tab A: ${e}.`);
          }
          info(`Fetching result from tab B for ${TEST_MODE_NAMES[aMode]}.`);
          let resultB;
          try {
            resultB = await getResultFunc(tabInfoB.browser);
          } catch (e) {
            ok(false, `Caught error while getting result from Tab B: ${e}.`);
          }
          // Compare results.
          let result = false;
          let shouldIsolate =
            aMode !== TEST_MODE_NO_ISOLATION && tabSettingA !== tabSettingB;
          if (aCompareResultFunc) {
            result = await aCompareResultFunc(shouldIsolate, resultA, resultB);
          } else {
            result = shouldIsolate ? resultA !== resultB : resultA === resultB;
          }

          let msg =
            `Result of Testing ${TEST_MODE_NAMES[aMode]} for ` +
            `isolation ${shouldIsolate ? "on" : "off"} with TabSettingA ` +
            `${tabSettingA} and tabSettingB ${tabSettingB}` +
            `, resultA = ${resultA}, resultB = ${resultB}`;

          ok(result, msg);
        }

        // Close Tabs.
        BrowserTestUtils.removeTab(tabInfoA.tab);
        BrowserTestUtils.removeTab(tabInfoB.tab);

        // A workaround for avoiding a timing issue in Fission. This workaround
        // makes sure that the shutdown process between parent and content
        // is finished before the next round of testing.
        if (SpecialPowers.useRemoteSubframes) {
          await new Promise(resolve => {
            let observer = (subject, topic, data) => {
              if (topic === "ipc:content-shutdown") {
                Services.obs.removeObserver(observer, "ipc:content-shutdown");
                resolve();
              }
            };
            Services.obs.addObserver(observer, "ipc:content-shutdown");
          });
        }
      }
    });
  },
};
