/* 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/. */

#include "TelemetryFixture.h"
#include "TelemetryTestHelpers.h"
#include "core/TelemetryEvent.h"
#include "gtest/gtest.h"
#include "js/Array.h"               // JS::GetArrayLength
#include "js/PropertyAndElement.h"  // JS_GetElement, JS_GetProperty
#include "js/TypeDecls.h"
#include "mozilla/BasePrincipal.h"
#include "mozilla/Preferences.h"
#include "mozilla/RefPtr.h"
#include "mozilla/Telemetry.h"
#include "nsContentSecurityManager.h"
#include "nsContentSecurityUtils.h"
#include "nsContentUtils.h"
#include "nsIContentPolicy.h"
#include "nsILoadInfo.h"
#include "nsNetUtil.h"
#include "nsStringFwd.h"

using namespace mozilla;
using namespace TelemetryTestHelpers;

extern Atomic<bool, mozilla::Relaxed> sJSHacksChecked;
extern Atomic<bool, mozilla::Relaxed> sJSHacksPresent;
extern Atomic<bool, mozilla::Relaxed> sCSSHacksChecked;
extern Atomic<bool, mozilla::Relaxed> sCSSHacksPresent;

TEST_F(TelemetryTestFixture, UnexpectedPrivilegedLoadsTelemetryTest) {
  // Enable telemetry pref.
  bool prefDefault = Preferences::GetBool(
      "dom.security.unexpected_system_load_telemetry_enabled");
  Preferences::SetBool("dom.security.unexpected_system_load_telemetry_enabled",
                       true);

  // Disable JS/CSS Hacks Detection, which would consider this current profile
  // as uninteresting for our measurements:
  bool origJSHacksPresent = sJSHacksPresent;
  bool origJSHacksChecked = sJSHacksChecked;
  sJSHacksPresent = false;
  sJSHacksChecked = true;
  bool origCSSHacksPresent = sCSSHacksPresent;
  bool origCSSHacksChecked = sCSSHacksChecked;
  sCSSHacksPresent = false;
  sCSSHacksChecked = true;

  struct testResults {
    nsCString fileinfo;
    nsCString extraValueContenttype;
    nsCString extraValueRemotetype;
    nsCString extraValueFiledetails;
    nsCString extraValueRedirects;
  };

  struct testCasesAndResults {
    nsCString urlstring;
    nsContentPolicyType contentType;
    nsCString remoteType;
    testResults expected;
  };

  AutoJSContextWithGlobal cx(mCleanGlobal);
  // Make sure we don't look at events from other tests.
  (void)mTelemetry->ClearEvents();

  // required for telemetry lookups
  constexpr auto category = "security"_ns;
  constexpr auto method = "unexpectedload"_ns;
  constexpr auto object = "systemprincipal"_ns;
  constexpr auto extraKeyContenttype = "contenttype"_ns;
  constexpr auto extraKeyRemotetype = "remotetype"_ns;
  constexpr auto extraKeyFiledetails = "filedetails"_ns;
  constexpr auto extraKeyRedirects = "redirects"_ns;

  // some cases from TestFilenameEvalParser
  // no need to replicate all scenarios?!
  testCasesAndResults myTestCases[] = {
      {"chrome://firegestures/content/browser.js"_ns,
       nsContentPolicyType::TYPE_SCRIPT,
       "web"_ns,
       {"chromeuri"_ns, "TYPE_SCRIPT"_ns, "web"_ns,
        "chrome://firegestures/content/browser.js"_ns, ""_ns}},
      {"resource://firegestures/content/browser.js"_ns,
       nsContentPolicyType::TYPE_SCRIPT,
       "web"_ns,
       {"resourceuri"_ns, "TYPE_SCRIPT"_ns, "web"_ns,
        "resource://firegestures/content/browser.js"_ns, ""_ns}},
      {// test that we don't report blob details
       // ..and test that we strip of URLs from remoteTypes
       "blob://000-000"_ns,
       nsContentPolicyType::TYPE_SCRIPT,
       "webIsolated=https://blob.example/"_ns,
       {"bloburi"_ns, "TYPE_SCRIPT"_ns, "webIsolated"_ns, "unknown"_ns, ""_ns}},
      {// test for cases where finalURI is null, due to a broken nested URI
       // .. like malformed moz-icon URLs
       "moz-icon:blahblah"_ns,
       nsContentPolicyType::TYPE_DOCUMENT,
       "web"_ns,
       {"other"_ns, "TYPE_DOCUMENT"_ns, "web"_ns, "unknown"_ns, ""_ns}},
      {// we dont report data urls
       // ..and test that we strip of URLs from remoteTypes
       "data://blahblahblah"_ns,
       nsContentPolicyType::TYPE_SCRIPT,
       "webCOOP+COEP=https://data.example"_ns,
       {"dataurl"_ns, "TYPE_SCRIPT"_ns, "webCOOP+COEP"_ns, "unknown"_ns,
        ""_ns}},
      {// handle data URLs for webextension content scripts differently
       // .. by noticing their annotation
       "data:text/css;extension=style;charset=utf-8,/* some css here */"_ns,
       nsContentPolicyType::TYPE_STYLESHEET,
       "web"_ns,
       {"dataurl-extension-contentstyle"_ns, "TYPE_STYLESHEET"_ns, "web"_ns,
        "unknown"_ns, ""_ns}},
      {// we only report file URLs on windows, where we can easily sanitize
       "file://c/users/tom/file.txt"_ns,
       nsContentPolicyType::TYPE_SCRIPT,
       "web"_ns,
       {
#if defined(XP_WIN)
           "sanitizedWindowsURL"_ns, "TYPE_SCRIPT"_ns, "web"_ns,
           "file://.../file.txt"_ns, ""_ns

#else
           "other"_ns, "TYPE_SCRIPT"_ns, "web"_ns, "unknown"_ns, ""_ns
#endif
       }},
      {// test for one redirect
       "moz-extension://abcdefab-1234-4321-0000-abcdefabcdef/js/assets.js"_ns,
       nsContentPolicyType::TYPE_SCRIPT,
       "web"_ns,
       {"extension_uri"_ns, "TYPE_SCRIPT"_ns, "web"_ns,
        // the extension-id is made-up, so the extension will report failure
        "moz-extension://[failed finding addon by host]/js/assets.js"_ns,
        "https"_ns}},
      {// test for cases where finalURI is empty
       ""_ns,
       nsContentPolicyType::TYPE_STYLESHEET,
       "web"_ns,
       {"other"_ns, "TYPE_STYLESHEET"_ns, "web"_ns, "unknown"_ns, ""_ns}},
      {// test for cases where finalURI is null, due to the struct layout, we'll
       // override the URL with nullptr in loop below.
       "URLWillResultInNullPtr"_ns,
       nsContentPolicyType::TYPE_SCRIPT,
       "web"_ns,
       {"other"_ns, "TYPE_SCRIPT"_ns, "web"_ns, "unknown"_ns, ""_ns}},
  };

  int i = 0;
  for (auto const& currentTest : myTestCases) {
    nsresult rv;
    nsCOMPtr<nsIURI> uri;

    // special-casing for a case where the uri is null
    if (!currentTest.urlstring.Equals("URLWillResultInNullPtr")) {
      NS_NewURI(getter_AddRefs(uri), currentTest.urlstring);
    }

    // We can't create channels for chrome: URLs unless they are in a chrome
    // registry that maps them into the actual destination URL (usually
    // file://). It seems that gtest don't have chrome manifest registered, so
    // we'll use a mockChannel with a mockUri.
    nsCOMPtr<nsIURI> mockUri;
    rv = NS_NewURI(getter_AddRefs(mockUri), "http://example.com"_ns);
    ASSERT_EQ(rv, NS_OK) << "Could not create mockUri";
    nsCOMPtr<nsIChannel> mockChannel;
    nsCOMPtr<nsIIOService> service = do_GetIOService();
    if (!service) {
      ASSERT_TRUE(false)
      << "Couldn't initialize IOService";
    }
    rv = service->NewChannelFromURI(
        mockUri, nullptr, nsContentUtils::GetSystemPrincipal(),
        nsContentUtils::GetSystemPrincipal(), 0, currentTest.contentType,
        getter_AddRefs(mockChannel));
    ASSERT_EQ(rv, NS_OK) << "Could not create a mock channel";
    nsCOMPtr<nsILoadInfo> mockLoadInfo = mockChannel->LoadInfo();

    // We're adding a redirect entry for one specific test
    if (currentTest.urlstring.EqualsASCII(
            "moz-extension://abcdefab-1234-4321-0000-abcdefabcdef/js/"
            "assets.js")) {
      nsCOMPtr<nsIURI> redirUri;
      NS_NewURI(getter_AddRefs(redirUri),
                "https://www.analytics.example/analytics.js"_ns);
      nsCOMPtr<nsIPrincipal> redirPrincipal =
          BasePrincipal::CreateContentPrincipal(redirUri, OriginAttributes());
      nsCOMPtr<nsIChannel> redirectChannel;
      (void)service->NewChannelFromURI(redirUri, nullptr, redirPrincipal,
                                       nullptr, 0, currentTest.contentType,
                                       getter_AddRefs(redirectChannel));

      mockLoadInfo->AppendRedirectHistoryEntry(redirectChannel, false);
    }

    // this will record the event
    nsContentSecurityManager::MeasureUnexpectedPrivilegedLoads(
        mockLoadInfo, uri, currentTest.remoteType);

    // let's inspect the recorded events

    JS::Rooted<JS::Value> eventsSnapshot(cx.GetJSContext());
    GetEventSnapshot(cx.GetJSContext(), &eventsSnapshot);

    ASSERT_TRUE(EventPresent(cx.GetJSContext(), eventsSnapshot, category,
                             method, object))
    << "Test event with value and extra must be present.";

    // Convert eventsSnapshot into array/object
    JSContext* aCx = cx.GetJSContext();
    JS::Rooted<JSObject*> arrayObj(aCx, &eventsSnapshot.toObject());

    JS::Rooted<JS::Value> eventRecord(aCx);
    ASSERT_TRUE(JS_GetElement(aCx, arrayObj, i++, &eventRecord))
    << "Must be able to get record.";  // record is already undefined :-/

    ASSERT_TRUE(!eventRecord.isUndefined())
    << "eventRecord should not be undefined";

    JS::Rooted<JSObject*> recordArray(aCx, &eventRecord.toObject());
    uint32_t recordLength;
    ASSERT_TRUE(JS::GetArrayLength(aCx, recordArray, &recordLength))
    << "Event record array must have length.";
    ASSERT_TRUE(recordLength == 6)
    << "Event record must have 6 elements.";

    JS::Rooted<JS::Value> str(aCx);
    nsAutoJSString jsStr;
    // The fileinfo string is at index 4
    ASSERT_TRUE(JS_GetElement(aCx, recordArray, 4, &str))
    << "Must be able to get value.";
    ASSERT_TRUE(jsStr.init(aCx, str))
    << "Value must be able to be init'd to a jsstring.";

    ASSERT_STREQ(NS_ConvertUTF16toUTF8(jsStr).get(),
                 currentTest.expected.fileinfo.get())
        << "Reported fileinfo '" << NS_ConvertUTF16toUTF8(jsStr).get()
        << " 'equals expected value: " << currentTest.expected.fileinfo.get();

    // Extra is at index 5
    JS::Rooted<JS::Value> obj(aCx);
    ASSERT_TRUE(JS_GetElement(aCx, recordArray, 5, &obj))
    << "Must be able to get extra data";
    JS::Rooted<JSObject*> extraObj(aCx, &obj.toObject());
    // looking at remotetype extra for content type
    JS::Rooted<JS::Value> extraValC(aCx);
    ASSERT_TRUE(
        JS_GetProperty(aCx, extraObj, extraKeyContenttype.get(), &extraValC))
    << "Must be able to get the extra key's value for contenttype";
    ASSERT_TRUE(jsStr.init(aCx, extraValC))
    << "Extra value contenttype must be able to be init'd to a jsstring.";
    ASSERT_STREQ(NS_ConvertUTF16toUTF8(jsStr).get(),
                 currentTest.expected.extraValueContenttype.get())
        << "Reported value for extra contenttype '"
        << NS_ConvertUTF16toUTF8(jsStr).get()
        << "' should equals supplied value"
        << currentTest.expected.extraValueContenttype.get();
    // and again for remote type
    JS::Rooted<JS::Value> extraValP(aCx);
    ASSERT_TRUE(
        JS_GetProperty(aCx, extraObj, extraKeyRemotetype.get(), &extraValP))
    << "Must be able to get the extra key's value for remotetype";
    ASSERT_TRUE(jsStr.init(aCx, extraValP))
    << "Extra value remotetype must be able to be init'd to a jsstring.";
    ASSERT_STREQ(NS_ConvertUTF16toUTF8(jsStr).get(),
                 currentTest.expected.extraValueRemotetype.get())
        << "Reported value for extra remotetype '"
        << NS_ConvertUTF16toUTF8(jsStr).get()
        << "' should equals supplied value: "
        << currentTest.expected.extraValueRemotetype.get();
    // repeating the same for filedetails extra
    JS::Rooted<JS::Value> extraValF(aCx);
    ASSERT_TRUE(
        JS_GetProperty(aCx, extraObj, extraKeyFiledetails.get(), &extraValF))
    << "Must be able to get the extra key's value for filedetails";
    ASSERT_TRUE(jsStr.init(aCx, extraValF))
    << "Extra value filedetails must be able to be init'd to a jsstring.";
    ASSERT_STREQ(NS_ConvertUTF16toUTF8(jsStr).get(),
                 currentTest.expected.extraValueFiledetails.get())
        << "Reported value for extra filedetails '"
        << NS_ConvertUTF16toUTF8(jsStr).get() << "'should equals supplied value"
        << currentTest.expected.extraValueFiledetails.get();
    // checking the extraKeyRedirects match
    JS::Rooted<JS::Value> extraValRedirects(aCx);
    ASSERT_TRUE(JS_GetProperty(aCx, extraObj, extraKeyRedirects.get(),
                               &extraValRedirects))
    << "Must be able to get the extra value for redirects";
    ASSERT_TRUE(jsStr.init(aCx, extraValRedirects))
    << "Extra value redirects must be able to be init'd to a jsstring";
    ASSERT_STREQ(NS_ConvertUTF16toUTF8(jsStr).get(),
                 currentTest.expected.extraValueRedirects.get())
        << "Reported value for extra redirect '"
        << NS_ConvertUTF16toUTF8(jsStr).get()
        << "' should equals supplied value: "
        << currentTest.expected.extraValueRedirects.get();
  }

  // Re-store JS/CSS hacks detection state
  sJSHacksPresent = origJSHacksPresent;
  sJSHacksChecked = origJSHacksChecked;
  sCSSHacksPresent = origCSSHacksPresent;
  sCSSHacksChecked = origCSSHacksChecked;

  // Restore telemetry pref
  Preferences::SetBool("dom.security.unexpected_system_load_telemetry_enabled",
                       prefDefault);
}
