/* 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";

XPCOMUtils.defineLazyScriptGetter(
  this,
  ["FullScreen"],
  "chrome://browser/content/browser-fullScreenAndPointerLock.js"
);

const TEST_URL = "https://example.com/";
var gAuthenticatorId;

/**
 * Waits for the PopupNotifications button enable delay to expire so the
 * Notification can be interacted with using the buttons.
 */
async function waitForPopupNotificationSecurityDelay() {
  let notification = PopupNotifications.panel.firstChild.notification;
  let notificationEnableDelayMS = Services.prefs.getIntPref(
    "security.notification_enable_delay"
  );
  await TestUtils.waitForCondition(
    () => {
      let timeSinceShown = Cu.now() - notification.timeShown;
      return timeSinceShown > notificationEnableDelayMS;
    },
    "Wait for security delay to expire",
    500,
    50
  );
}

add_task(async function test_setup_usbtoken() {
  return SpecialPowers.pushPrefEnv({
    set: [
      ["security.webauth.webauthn_enable_softtoken", false],
      ["security.webauth.webauthn_enable_usbtoken", true],
    ],
  });
});
add_task(test_register);
add_task(test_register_escape);
add_task(test_sign);
add_task(test_sign_escape);
add_task(test_tab_switching);
add_task(test_window_switching);
add_task(async function test_setup_softtoken() {
  gAuthenticatorId = add_virtual_authenticator();
  return SpecialPowers.pushPrefEnv({
    set: [
      ["browser.fullscreen.autohide", true],
      ["full-screen-api.enabled", true],
      ["full-screen-api.allow-trusted-requests-only", false],
      ["security.webauth.webauthn_enable_softtoken", true],
      ["security.webauth.webauthn_enable_usbtoken", false],
    ],
  });
});
add_task(test_fullscreen_show_nav_toolbar);
add_task(test_no_fullscreen_dom);
add_task(test_register_direct_with_consent);
add_task(test_register_direct_without_consent);
add_task(test_select_sign_result);

function promiseNavToolboxStatus(aExpectedStatus) {
  let navToolboxStatus;
  return TestUtils.topicObserved("fullscreen-nav-toolbox", (subject, data) => {
    navToolboxStatus = data;
    return data == aExpectedStatus;
  }).then(() =>
    Assert.equal(
      navToolboxStatus,
      aExpectedStatus,
      "nav toolbox is " + aExpectedStatus
    )
  );
}

function promiseFullScreenPaint(aExpectedStatus) {
  return TestUtils.topicObserved("fullscreen-painted");
}

function triggerMainPopupCommand(popup) {
  info("triggering main command");
  let notifications = popup.childNodes;
  ok(notifications.length, "at least one notification displayed");
  let notification = notifications[0];
  info("triggering command: " + notification.getAttribute("buttonlabel"));

  return EventUtils.synthesizeMouseAtCenter(notification.button, {});
}

let expectNotAllowedError = expectError("NotAllowed");

function verifyAnonymizedCertificate(aResult) {
  return webAuthnDecodeCBORAttestation(aResult.attObj).then(
    ({ fmt, attStmt }) => {
      is(fmt, "none", "Is a None Attestation");
      is(typeof attStmt, "object", "attStmt is a map");
      is(Object.keys(attStmt).length, 0, "attStmt is empty");
    }
  );
}

async function verifyDirectCertificate(aResult) {
  let clientDataHash = await crypto.subtle
    .digest("SHA-256", aResult.clientDataJSON)
    .then(digest => new Uint8Array(digest));
  let { fmt, attStmt, authData, authDataObj } =
    await webAuthnDecodeCBORAttestation(aResult.attObj);
  is(fmt, "packed", "Is a Packed Attestation");
  let signedData = new Uint8Array(authData.length + clientDataHash.length);
  signedData.set(authData);
  signedData.set(clientDataHash, authData.length);
  let valid = await verifySignature(
    authDataObj.publicKeyHandle,
    signedData,
    new Uint8Array(attStmt.sig)
  );
  ok(valid, "Signature is valid.");
}

async function test_register() {
  // Open a new tab.
  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);

  // Request a new credential and wait for the prompt.
  let notificationPromise = promiseNotification("webauthn-prompt-presence");
  let active = true;
  let request = promiseWebAuthnMakeCredential(tab)
    .then(arrivingHereIsBad)
    .catch(expectNotAllowedError)
    .then(() => (active = false));
  await notificationPromise;

  // Cancel the request with the button.
  ok(active, "request should still be active");
  PopupNotifications.panel.firstElementChild.button.click();
  await request;

  // Close tab.
  await BrowserTestUtils.removeTab(tab);
}

async function test_register_escape() {
  // Open a new tab.
  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);

  // Request a new credential and wait for the prompt.
  let notificationPromise = promiseNotification("webauthn-prompt-presence");
  let active = true;
  let request = promiseWebAuthnMakeCredential(tab)
    .then(arrivingHereIsBad)
    .catch(expectNotAllowedError)
    .then(() => (active = false));
  await notificationPromise;

  // Cancel the request by hitting escape.
  ok(active, "request should still be active");
  EventUtils.synthesizeKey("KEY_Escape");
  await request;

  // Close tab.
  await BrowserTestUtils.removeTab(tab);
}

async function test_sign() {
  // Open a new tab.
  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);

  // Request a new assertion and wait for the prompt.
  let notificationPromise = promiseNotification("webauthn-prompt-presence");
  let active = true;
  let request = promiseWebAuthnGetAssertion(tab)
    .then(arrivingHereIsBad)
    .catch(expectNotAllowedError)
    .then(() => (active = false));
  await notificationPromise;

  // Cancel the request with the button.
  ok(active, "request should still be active");
  PopupNotifications.panel.firstElementChild.button.click();
  await request;

  // Close tab.
  await BrowserTestUtils.removeTab(tab);
}

async function test_sign_escape() {
  // Open a new tab.
  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);

  // Request a new assertion and wait for the prompt.
  let notificationPromise = promiseNotification("webauthn-prompt-presence");
  let active = true;
  let request = promiseWebAuthnGetAssertion(tab)
    .then(arrivingHereIsBad)
    .catch(expectNotAllowedError)
    .then(() => (active = false));
  await notificationPromise;

  // Cancel the request by hitting escape.
  ok(active, "request should still be active");
  EventUtils.synthesizeKey("KEY_Escape");
  await request;

  // Close tab.
  await BrowserTestUtils.removeTab(tab);
}

// Add two tabs, open WebAuthn in the first, switch, assert the prompt is
// not visible, switch back, assert the prompt is there and cancel it.
async function test_tab_switching() {
  // Open a new tab.
  let tab_one = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);

  // Request a new credential and wait for the prompt.
  let notificationPromise = promiseNotification("webauthn-prompt-presence");
  let active = true;
  let request = promiseWebAuthnMakeCredential(tab_one)
    .then(arrivingHereIsBad)
    .catch(expectNotAllowedError)
    .then(() => (active = false));
  await notificationPromise;
  is(PopupNotifications.panel.state, "open", "Doorhanger is visible");

  // Open and switch to a second tab.
  let tab_two = await BrowserTestUtils.openNewForegroundTab(
    gBrowser,
    "https://example.org/"
  );

  await TestUtils.waitForCondition(
    () => PopupNotifications.panel.state == "closed"
  );
  is(PopupNotifications.panel.state, "closed", "Doorhanger is hidden");

  let notificationPromise2 = promiseNotification("webauthn-prompt-presence");

  // Go back to the first tab
  await BrowserTestUtils.removeTab(tab_two);

  await notificationPromise2;

  await TestUtils.waitForCondition(
    () => PopupNotifications.panel.state == "open"
  );
  is(PopupNotifications.panel.state, "open", "Doorhanger is visible");

  // Cancel the request.
  ok(active, "request should still be active");
  await triggerMainPopupCommand(PopupNotifications.panel);
  await request;
  ok(!active, "request should be stopped");

  // Close tab.
  await BrowserTestUtils.removeTab(tab_one);
}

// Add two tabs, open WebAuthn in the first, switch, assert the prompt is
// not visible, switch back, assert the prompt is there and cancel it.
async function test_window_switching() {
  // Open a new tab.
  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);

  // Request a new credential and wait for the prompt.
  let notificationPromise = promiseNotification("webauthn-prompt-presence");
  let active = true;
  let request = promiseWebAuthnMakeCredential(tab)
    .then(arrivingHereIsBad)
    .catch(expectNotAllowedError)
    .then(() => (active = false));
  await notificationPromise;

  await TestUtils.waitForCondition(
    () => PopupNotifications.panel.state == "open"
  );
  is(PopupNotifications.panel.state, "open", "Doorhanger is visible");

  // Open and switch to a second window
  let new_window = await BrowserTestUtils.openNewBrowserWindow();
  await SimpleTest.promiseFocus(new_window);

  await TestUtils.waitForCondition(
    () => new_window.PopupNotifications.panel.state == "closed"
  );
  is(
    new_window.PopupNotifications.panel.state,
    "closed",
    "Doorhanger is hidden"
  );

  // Go back to the first tab
  await BrowserTestUtils.closeWindow(new_window);
  await SimpleTest.promiseFocus(window);

  await TestUtils.waitForCondition(
    () => PopupNotifications.panel.state == "open"
  );
  is(PopupNotifications.panel.state, "open", "Doorhanger is still visible");

  // Cancel the request.
  ok(active, "request should still be active");
  await triggerMainPopupCommand(PopupNotifications.panel);
  await request;
  ok(!active, "request should be stopped");

  // Close tab.
  await BrowserTestUtils.removeTab(tab);
}

async function test_register_direct_with_consent() {
  // Open a new tab.
  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);

  // Request a new credential with direct attestation and wait for the prompt.
  let notificationPromise = promiseNotification(
    "webauthn-prompt-register-direct"
  );
  let request = promiseWebAuthnMakeCredential(tab, "direct");
  await notificationPromise;

  // Click "Allow".
  PopupNotifications.panel.firstElementChild.button.click();

  // Ensure we got "direct" attestation.
  await request.then(verifyDirectCertificate);

  // Close tab.
  await BrowserTestUtils.removeTab(tab);
}

async function test_register_direct_without_consent() {
  // Open a new tab.
  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);

  // Request a new credential with direct attestation and wait for the prompt.
  let notificationPromise = promiseNotification(
    "webauthn-prompt-register-direct"
  );
  let request = promiseWebAuthnMakeCredential(tab, "direct");
  await notificationPromise;

  // Click "Block".
  PopupNotifications.panel.firstElementChild.secondaryButton.click();

  // Ensure we got "none" attestation.
  await request.then(verifyAnonymizedCertificate);

  // Close tab.
  await BrowserTestUtils.removeTab(tab);
}

async function test_select_sign_result() {
  // Open a new tab.
  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);

  // Make two discoverable credentials for the same RP ID so that
  // the user has to select one to return.
  let cred1 = await addCredential(gAuthenticatorId, "example.com");
  let cred2 = await addCredential(gAuthenticatorId, "example.com");

  let notificationPromise = promiseNotification(
    "webauthn-prompt-select-sign-result"
  );
  let active = true;
  let request = promiseWebAuthnGetAssertionDiscoverable(tab)
    .then(arrivingHereIsBad)
    .catch(expectNotAllowedError)
    .then(() => (active = false));

  // Ensure the selection prompt is shown
  await notificationPromise;

  ok(active, "request is active");

  // Cancel the request
  PopupNotifications.panel.firstElementChild.button.click();
  await request;

  await removeCredential(gAuthenticatorId, cred1);
  await removeCredential(gAuthenticatorId, cred2);
  await BrowserTestUtils.removeTab(tab);
}

async function test_fullscreen_show_nav_toolbar() {
  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);

  // Start with the window fullscreen and the nav toolbox hidden
  let fullscreenState = window.fullScreen;

  let navToolboxHiddenPromise = promiseNavToolboxStatus("hidden");

  window.fullScreen = true;
  FullScreen.hideNavToolbox(false);

  await navToolboxHiddenPromise;

  // Request a new credential with direct attestation. The consent prompt will
  // keep the request active until we can verify that the nav toolbar is shown.
  let promptPromise = promiseNotification("webauthn-prompt-register-direct");
  let navToolboxShownPromise = promiseNavToolboxStatus("shown");

  let active = true;
  let requestPromise = promiseWebAuthnMakeCredential(tab, "direct").then(
    () => (active = false)
  );

  await Promise.all([promptPromise, navToolboxShownPromise]);

  ok(active, "request is active");
  ok(window.fullScreen, "window is fullscreen");

  // Proceed through the consent prompt.
  PopupNotifications.panel.firstElementChild.secondaryButton.click();
  await requestPromise;

  window.fullScreen = fullscreenState;

  // Close tab.
  await BrowserTestUtils.removeTab(tab);
}

async function test_no_fullscreen_dom() {
  let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);

  let fullScreenPaintPromise = promiseFullScreenPaint();
  // Make a DOM element fullscreen
  await ContentTask.spawn(tab.linkedBrowser, [], () => {
    return content.document.body.requestFullscreen();
  });
  await fullScreenPaintPromise;
  ok(!!document.fullscreenElement, "a DOM element is fullscreen");

  // Request a new credential with direct attestation. The consent prompt will
  // keep the request active until we can verify that we've left fullscreen.
  let promptPromise = promiseNotification("webauthn-prompt-register-direct");
  fullScreenPaintPromise = promiseFullScreenPaint();

  let active = true;
  let requestPromise = promiseWebAuthnMakeCredential(tab, "direct").then(
    () => (active = false)
  );

  await Promise.all([promptPromise, fullScreenPaintPromise]);

  ok(active, "request is active");
  ok(!document.fullscreenElement, "no DOM element is fullscreen");

  // Proceed through the consent prompt.
  await waitForPopupNotificationSecurityDelay();
  PopupNotifications.panel.firstElementChild.secondaryButton.click();
  await requestPromise;

  // Close tab.
  await BrowserTestUtils.removeTab(tab);
}
