summaryrefslogtreecommitdiffstats
path: root/toolkit/components/captivedetect
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/captivedetect
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/captivedetect')
-rw-r--r--toolkit/components/captivedetect/CaptiveDetect.sys.mjs545
-rw-r--r--toolkit/components/captivedetect/components.conf14
-rw-r--r--toolkit/components/captivedetect/moz.build24
-rw-r--r--toolkit/components/captivedetect/nsICaptivePortalDetector.idl53
-rw-r--r--toolkit/components/captivedetect/test/captive-portal-simulator.js76
-rw-r--r--toolkit/components/captivedetect/test/unit/head_setprefs.js76
-rw-r--r--toolkit/components/captivedetect/test/unit/test_abort.js52
-rw-r--r--toolkit/components/captivedetect/test/unit/test_abort_during_user_login.js64
-rw-r--r--toolkit/components/captivedetect/test/unit/test_abort_ongoing_request.js74
-rw-r--r--toolkit/components/captivedetect/test/unit/test_abort_pending_request.js71
-rw-r--r--toolkit/components/captivedetect/test/unit/test_captive_portal_found.js65
-rw-r--r--toolkit/components/captivedetect/test/unit/test_captive_portal_found_303.js74
-rw-r--r--toolkit/components/captivedetect/test/unit/test_captive_portal_not_found.js54
-rw-r--r--toolkit/components/captivedetect/test/unit/test_captive_portal_not_found_404.js47
-rw-r--r--toolkit/components/captivedetect/test/unit/test_multiple_requests.js83
-rw-r--r--toolkit/components/captivedetect/test/unit/test_user_cancel.js52
-rw-r--r--toolkit/components/captivedetect/test/unit/xpcshell.toml23
17 files changed, 1447 insertions, 0 deletions
diff --git a/toolkit/components/captivedetect/CaptiveDetect.sys.mjs b/toolkit/components/captivedetect/CaptiveDetect.sys.mjs
new file mode 100644
index 0000000000..9008fe8a08
--- /dev/null
+++ b/toolkit/components/captivedetect/CaptiveDetect.sys.mjs
@@ -0,0 +1,545 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+const DEBUG = false; // set to true to show debug messages
+
+const kCAPTIVEPORTALDETECTOR_CID = Components.ID(
+ "{d9cd00ba-aa4d-47b1-8792-b1fe0cd35060}"
+);
+
+const kOpenCaptivePortalLoginEvent = "captive-portal-login";
+const kAbortCaptivePortalLoginEvent = "captive-portal-login-abort";
+const kCaptivePortalLoginSuccessEvent = "captive-portal-login-success";
+const kCaptivePortalCheckComplete = "captive-portal-check-complete";
+
+function URLFetcher(url, timeout) {
+ let self = this;
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", url, true);
+ // Prevent the request from reading from the cache.
+ xhr.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ // Prevent the request from writing to the cache.
+ xhr.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
+ // Prevent privacy leaks
+ xhr.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS;
+ // Use the system's resolver for this check
+ xhr.channel.setTRRMode(Ci.nsIRequest.TRR_DISABLED_MODE);
+ // We except this from being classified
+ xhr.channel.loadFlags |= Ci.nsIChannel.LOAD_BYPASS_URL_CLASSIFIER;
+ // Prevent HTTPS-Only Mode from upgrading the request.
+ xhr.channel.loadInfo.httpsOnlyStatus |= Ci.nsILoadInfo.HTTPS_ONLY_EXEMPT;
+ // Allow deprecated HTTP request from SystemPrincipal
+ xhr.channel.loadInfo.allowDeprecatedSystemRequests = true;
+
+ // We don't want to follow _any_ redirects
+ xhr.channel.QueryInterface(Ci.nsIHttpChannel).redirectionLimit = 0;
+
+ // bug 1666072 - firefox.com returns a HSTS header triggering a https upgrade
+ // but the upgrade triggers an internal redirect causing an incorrect locked
+ // portal notification. We exclude CP detection from STS.
+ xhr.channel.QueryInterface(Ci.nsIHttpChannel).allowSTS = false;
+
+ // The Cache-Control header is only interpreted by proxies and the
+ // final destination. It does not help if a resource is already
+ // cached locally.
+ xhr.setRequestHeader("Cache-Control", "no-cache");
+ // HTTP/1.0 servers might not implement Cache-Control and
+ // might only implement Pragma: no-cache
+ xhr.setRequestHeader("Pragma", "no-cache");
+
+ xhr.timeout = timeout;
+ xhr.ontimeout = function () {
+ self.ontimeout();
+ };
+ xhr.onerror = function () {
+ self.onerror();
+ };
+ xhr.onreadystatechange = function (oEvent) {
+ if (xhr.readyState === 4) {
+ if (self._isAborted) {
+ return;
+ }
+ if (xhr.status === 200) {
+ self.onsuccess(xhr.responseText);
+ } else if (xhr.status) {
+ self.onredirectorerror(xhr.status);
+ } else if (
+ xhr.channel &&
+ xhr.channel.status == Cr.NS_ERROR_REDIRECT_LOOP
+ ) {
+ // For some redirects we don't get a status, so we need to check it
+ // this way. This only works because we set the redirectionLimit to 0.
+ self.onredirectorerror(300);
+ // No need to invoke the onerror callback, we handled it here.
+ xhr.onerror = null;
+ }
+ }
+ };
+ xhr.send();
+ this._xhr = xhr;
+}
+
+URLFetcher.prototype = {
+ _isAborted: false,
+ ontimeout() {},
+ onerror() {},
+ abort() {
+ if (!this._isAborted) {
+ this._isAborted = true;
+ this._xhr.abort();
+ }
+ },
+};
+
+function LoginObserver(captivePortalDetector) {
+ const LOGIN_OBSERVER_STATE_DETACHED = 0; /* Should not monitor network activity since no ongoing login procedure */
+ const LOGIN_OBSERVER_STATE_IDLE = 1; /* No network activity currently, waiting for a longer enough idle period */
+ const LOGIN_OBSERVER_STATE_BURST = 2; /* Network activity is detected, probably caused by a login procedure */
+ const LOGIN_OBSERVER_STATE_VERIFY_NEEDED = 3; /* Verifing network accessiblity is required after a long enough idle */
+ const LOGIN_OBSERVER_STATE_VERIFYING = 4; /* LoginObserver is probing if public network is available */
+
+ let state = LOGIN_OBSERVER_STATE_DETACHED;
+
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ let activityDistributor = Cc[
+ "@mozilla.org/network/http-activity-distributor;1"
+ ].getService(Ci.nsIHttpActivityDistributor);
+ let urlFetcher = null;
+
+ let pageCheckingDone = function pageCheckingDone() {
+ if (state === LOGIN_OBSERVER_STATE_VERIFYING) {
+ urlFetcher = null;
+ // Finish polling the canonical site, switch back to idle state and
+ // waiting for next burst
+ state = LOGIN_OBSERVER_STATE_IDLE;
+ timer.initWithCallback(
+ observer,
+ captivePortalDetector._pollingTime,
+ timer.TYPE_ONE_SHOT
+ );
+ }
+ };
+
+ let checkPageContent = function checkPageContent() {
+ debug("checking if public network is available after the login procedure");
+
+ urlFetcher = new URLFetcher(
+ captivePortalDetector._canonicalSiteURL,
+ captivePortalDetector._maxWaitingTime
+ );
+ urlFetcher.ontimeout = pageCheckingDone;
+ urlFetcher.onerror = pageCheckingDone;
+ urlFetcher.onsuccess = function (content) {
+ if (captivePortalDetector.validateContent(content)) {
+ urlFetcher = null;
+ captivePortalDetector.executeCallback(true);
+ } else {
+ pageCheckingDone();
+ }
+ };
+ urlFetcher.onredirectorerror = pageCheckingDone;
+ };
+
+ // Public interface of LoginObserver
+ let observer = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIHttpActivityObserver",
+ "nsITimerCallback",
+ ]),
+
+ attach: function attach() {
+ if (state === LOGIN_OBSERVER_STATE_DETACHED) {
+ activityDistributor.addObserver(this);
+ state = LOGIN_OBSERVER_STATE_IDLE;
+ timer.initWithCallback(
+ this,
+ captivePortalDetector._pollingTime,
+ timer.TYPE_ONE_SHOT
+ );
+ debug("attach HttpObserver for login activity");
+ }
+ },
+
+ detach: function detach() {
+ if (state !== LOGIN_OBSERVER_STATE_DETACHED) {
+ if (urlFetcher) {
+ urlFetcher.abort();
+ urlFetcher = null;
+ }
+ activityDistributor.removeObserver(this);
+ timer.cancel();
+ state = LOGIN_OBSERVER_STATE_DETACHED;
+ debug("detach HttpObserver for login activity");
+ }
+ },
+
+ /*
+ * Treat all HTTP transactions as captive portal login activities.
+ */
+ observeActivity: function observeActivity(
+ aHttpChannel,
+ aActivityType,
+ aActivitySubtype,
+ aTimestamp,
+ aExtraSizeData,
+ aExtraStringData
+ ) {
+ if (
+ aActivityType ===
+ Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_HTTP_TRANSACTION &&
+ aActivitySubtype ===
+ Ci.nsIHttpActivityObserver.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE
+ ) {
+ switch (state) {
+ case LOGIN_OBSERVER_STATE_IDLE:
+ case LOGIN_OBSERVER_STATE_VERIFY_NEEDED:
+ state = LOGIN_OBSERVER_STATE_BURST;
+ break;
+ default:
+ break;
+ }
+ }
+ },
+
+ /*
+ * Check if login activity is finished according to HTTP burst.
+ */
+ notify: function notify() {
+ switch (state) {
+ case LOGIN_OBSERVER_STATE_BURST:
+ // Wait while network stays idle for a short period
+ state = LOGIN_OBSERVER_STATE_VERIFY_NEEDED;
+ // Fall through to start polling timer
+ case LOGIN_OBSERVER_STATE_IDLE:
+ // Just fall through to perform a captive portal check.
+ case LOGIN_OBSERVER_STATE_VERIFY_NEEDED:
+ // Polling the canonical website since network stays idle for a while
+ state = LOGIN_OBSERVER_STATE_VERIFYING;
+ checkPageContent();
+ break;
+
+ default:
+ break;
+ }
+ },
+ };
+
+ return observer;
+}
+
+export function CaptivePortalDetector() {
+ // Load preference
+ this._canonicalSiteURL = null;
+ this._canonicalSiteExpectedContent = null;
+
+ try {
+ this._canonicalSiteURL = Services.prefs.getCharPref(
+ "captivedetect.canonicalURL"
+ );
+ this._canonicalSiteExpectedContent = Services.prefs.getCharPref(
+ "captivedetect.canonicalContent"
+ );
+ } catch (e) {
+ debug("canonicalURL or canonicalContent not set.");
+ }
+
+ this._maxWaitingTime = Services.prefs.getIntPref(
+ "captivedetect.maxWaitingTime"
+ );
+ this._pollingTime = Services.prefs.getIntPref("captivedetect.pollingTime");
+ this._maxRetryCount = Services.prefs.getIntPref(
+ "captivedetect.maxRetryCount"
+ );
+ debug(
+ "Load Prefs {site=" +
+ this._canonicalSiteURL +
+ ",content=" +
+ this._canonicalSiteExpectedContent +
+ ",time=" +
+ this._maxWaitingTime +
+ "max-retry=" +
+ this._maxRetryCount +
+ "}"
+ );
+
+ // Create HttpObserver for monitoring the login procedure
+ this._loginObserver = LoginObserver(this);
+
+ this._nextRequestId = 0;
+ this._runningRequest = null;
+ this._requestQueue = []; // Maintain a progress table, store callbacks and the ongoing XHR
+ this._interfaceNames = {}; // Maintain names of the requested network interfaces
+
+ debug(
+ "CaptiveProtalDetector initiated, waiting for network connection established"
+ );
+}
+
+CaptivePortalDetector.prototype = {
+ classID: kCAPTIVEPORTALDETECTOR_CID,
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalDetector"]),
+
+ // nsICaptivePortalDetector
+ checkCaptivePortal: function checkCaptivePortal(aInterfaceName, aCallback) {
+ if (!this._canonicalSiteURL) {
+ throw Components.Exception("No canonical URL set up.");
+ }
+
+ // Prevent multiple requests on a single network interface
+ if (this._interfaceNames[aInterfaceName]) {
+ throw Components.Exception(
+ "Do not allow multiple request on one interface: " + aInterfaceName
+ );
+ }
+
+ let request = { interfaceName: aInterfaceName };
+ if (aCallback) {
+ let callback = aCallback.QueryInterface(Ci.nsICaptivePortalCallback);
+ request.callback = callback;
+ request.retryCount = 0;
+ }
+ this._addRequest(request);
+ },
+
+ abort: function abort(aInterfaceName) {
+ debug("abort for " + aInterfaceName);
+ this._removeRequest(aInterfaceName);
+ },
+
+ finishPreparation: function finishPreparation(aInterfaceName) {
+ debug('finish preparation phase for interface "' + aInterfaceName + '"');
+ if (
+ !this._runningRequest ||
+ this._runningRequest.interfaceName !== aInterfaceName
+ ) {
+ debug("invalid finishPreparation for " + aInterfaceName);
+ throw Components.Exception(
+ "only first request is allowed to invoke |finishPreparation|"
+ );
+ }
+
+ this._startDetection();
+ },
+
+ cancelLogin: function cancelLogin(eventId) {
+ debug('login canceled by user for request "' + eventId + '"');
+ // Captive portal login procedure is canceled by user
+ if (
+ this._runningRequest &&
+ this._runningRequest.hasOwnProperty("eventId")
+ ) {
+ let id = this._runningRequest.eventId;
+ if (eventId === id) {
+ this.executeCallback(false);
+ }
+ }
+ },
+
+ _applyDetection: function _applyDetection() {
+ debug("enter applyDetection(" + this._runningRequest.interfaceName + ")");
+
+ // Execute network interface preparation
+ if (this._runningRequest.hasOwnProperty("callback")) {
+ this._runningRequest.callback.prepare();
+ } else {
+ this._startDetection();
+ }
+ },
+
+ _startDetection: function _startDetection() {
+ debug(
+ "startDetection {site=" +
+ this._canonicalSiteURL +
+ ",content=" +
+ this._canonicalSiteExpectedContent +
+ ",time=" +
+ this._maxWaitingTime +
+ "}"
+ );
+ let self = this;
+
+ let urlFetcher = new URLFetcher(
+ this._canonicalSiteURL,
+ this._maxWaitingTime
+ );
+
+ let mayRetry = this._mayRetry.bind(this);
+
+ urlFetcher.ontimeout = mayRetry;
+ urlFetcher.onerror = mayRetry;
+ urlFetcher.onsuccess = function (content) {
+ if (self.validateContent(content)) {
+ self.executeCallback(true);
+ } else {
+ // Content of the canonical website has been overwrite
+ self._startLogin();
+ }
+ };
+ urlFetcher.onredirectorerror = function (status) {
+ if (status >= 300 && status <= 399) {
+ // The canonical website has been redirected to an unknown location
+ self._startLogin();
+ } else if (status === 511) {
+ // Got a RFC 6585 "Network Authentication Required" error page
+ self._startLogin();
+ } else {
+ mayRetry();
+ }
+ };
+
+ this._runningRequest.urlFetcher = urlFetcher;
+ },
+
+ _startLogin: function _startLogin() {
+ let id = this._allocateRequestId();
+ let details = {
+ type: kOpenCaptivePortalLoginEvent,
+ id,
+ url: this._canonicalSiteURL,
+ };
+ this._loginObserver.attach();
+ this._runningRequest.eventId = id;
+ this._sendEvent(kOpenCaptivePortalLoginEvent, details);
+ },
+
+ _mayRetry: function _mayRetry() {
+ if (
+ this._runningRequest &&
+ this._runningRequest.retryCount++ < this._maxRetryCount
+ ) {
+ debug(
+ "retry-Detection: " +
+ this._runningRequest.retryCount +
+ "/" +
+ this._maxRetryCount
+ );
+ this._startDetection();
+ } else {
+ this.executeCallback(false);
+ }
+ },
+
+ executeCallback: function executeCallback(success) {
+ if (this._runningRequest) {
+ debug("callback executed");
+ if (this._runningRequest.hasOwnProperty("callback")) {
+ this._runningRequest.callback.complete(success);
+ }
+
+ // Only when the request has a event id and |success| is true
+ // do we need to notify the login-success event.
+ if (this._runningRequest.hasOwnProperty("eventId") && success) {
+ let details = {
+ type: kCaptivePortalLoginSuccessEvent,
+ id: this._runningRequest.eventId,
+ };
+ this._sendEvent(kCaptivePortalLoginSuccessEvent, details);
+ }
+
+ // Continue the following request
+ this._runningRequest.complete = true;
+ this._removeRequest(this._runningRequest.interfaceName);
+ }
+ },
+
+ _sendEvent: function _sendEvent(topic, details) {
+ debug('sendEvent "' + JSON.stringify(details) + '"');
+ Services.obs.notifyObservers(this, topic, JSON.stringify(details));
+ },
+
+ validateContent: function validateContent(content) {
+ debug("received content: " + content);
+ let valid = content === this._canonicalSiteExpectedContent;
+ // We need a way to indicate that a check has been performed, and if we are
+ // still in a captive portal.
+ this._sendEvent(kCaptivePortalCheckComplete, !valid);
+ return valid;
+ },
+
+ _allocateRequestId: function _allocateRequestId() {
+ let newId = this._nextRequestId++;
+ return newId.toString();
+ },
+
+ _runNextRequest: function _runNextRequest() {
+ let nextRequest = this._requestQueue.shift();
+ if (nextRequest) {
+ this._runningRequest = nextRequest;
+ this._applyDetection();
+ }
+ },
+
+ _addRequest: function _addRequest(request) {
+ this._interfaceNames[request.interfaceName] = true;
+ this._requestQueue.push(request);
+ if (!this._runningRequest) {
+ this._runNextRequest();
+ }
+ },
+
+ _removeRequest: function _removeRequest(aInterfaceName) {
+ if (!this._interfaceNames[aInterfaceName]) {
+ return;
+ }
+
+ delete this._interfaceNames[aInterfaceName];
+
+ if (
+ this._runningRequest &&
+ this._runningRequest.interfaceName === aInterfaceName
+ ) {
+ this._loginObserver.detach();
+
+ if (!this._runningRequest.complete) {
+ // Abort the user login procedure
+ if (this._runningRequest.hasOwnProperty("eventId")) {
+ let details = {
+ type: kAbortCaptivePortalLoginEvent,
+ id: this._runningRequest.eventId,
+ };
+ this._sendEvent(kAbortCaptivePortalLoginEvent, details);
+ }
+
+ // Abort the ongoing HTTP request
+ if (this._runningRequest.hasOwnProperty("urlFetcher")) {
+ this._runningRequest.urlFetcher.abort();
+ }
+ }
+
+ debug("remove running request");
+ this._runningRequest = null;
+
+ // Continue next pending reqeust if the ongoing one has been aborted
+ this._runNextRequest();
+ return;
+ }
+
+ // Check if a pending request has been aborted
+ for (let i = 0; i < this._requestQueue.length; i++) {
+ if (this._requestQueue[i].interfaceName == aInterfaceName) {
+ this._requestQueue.splice(i, 1);
+
+ debug(
+ "remove pending request #" +
+ i +
+ ", remaining " +
+ this._requestQueue.length
+ );
+ break;
+ }
+ }
+ },
+};
+
+var debug;
+if (DEBUG) {
+ // eslint-disable-next-line no-global-assign
+ debug = function (s) {
+ dump("-*- CaptivePortalDetector component: " + s + "\n");
+ };
+} else {
+ // eslint-disable-next-line no-global-assign
+ debug = function (s) {};
+}
diff --git a/toolkit/components/captivedetect/components.conf b/toolkit/components/captivedetect/components.conf
new file mode 100644
index 0000000000..0497a13fd2
--- /dev/null
+++ b/toolkit/components/captivedetect/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+Classes = [
+ {
+ 'cid': '{d9cd00ba-aa4d-47b1-8792-b1fe0cd35060}',
+ 'contract_ids': ['@mozilla.org/toolkit/captive-detector;1'],
+ 'esModule': 'resource://gre/modules/CaptiveDetect.sys.mjs',
+ 'constructor': 'CaptivePortalDetector',
+ },
+]
diff --git a/toolkit/components/captivedetect/moz.build b/toolkit/components/captivedetect/moz.build
new file mode 100644
index 0000000000..5fa41fa97e
--- /dev/null
+++ b/toolkit/components/captivedetect/moz.build
@@ -0,0 +1,24 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Core", "Networking")
+
+XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"]
+
+XPIDL_SOURCES += [
+ "nsICaptivePortalDetector.idl",
+]
+
+XPIDL_MODULE = "captivedetect"
+
+EXTRA_JS_MODULES += [
+ "CaptiveDetect.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/toolkit/components/captivedetect/nsICaptivePortalDetector.idl b/toolkit/components/captivedetect/nsICaptivePortalDetector.idl
new file mode 100644
index 0000000000..ba8fff416b
--- /dev/null
+++ b/toolkit/components/captivedetect/nsICaptivePortalDetector.idl
@@ -0,0 +1,53 @@
+/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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 "nsISupports.idl"
+
+[scriptable, uuid(593fdeec-6284-4de8-b416-8e63cbdc695e)]
+interface nsICaptivePortalCallback : nsISupports
+{
+ /**
+ * Preparation for network interface before captive portal detection started.
+ */
+ void prepare();
+
+ /**
+ * Invoke callbacks after captive portal detection finished.
+ */
+ void complete(in bool success);
+};
+
+[scriptable, uuid(2f827c5a-f551-477f-af09-71adbfbd854a)]
+interface nsICaptivePortalDetector : nsISupports
+{
+ /**
+ * Perform captive portal detection on specific network interface.
+ * @param ifname The name of network interface, exception will be thrwon
+ * if the same interface has unfinished request.
+ * @param callback Callbacks when detection procedure starts and finishes.
+ */
+ void checkCaptivePortal(in AString ifname,
+ in nsICaptivePortalCallback callback);
+
+ /**
+ * Abort captive portal detection for specific network interface
+ * due to system failure, callback will not be invoked.
+ * @param ifname The name of network interface.
+ */
+ void abort(in AString ifname);
+
+ /**
+ * Cancel captive portal login procedure by user, callback will be invoked.
+ * @param eventId Login event id provided in |captive-portal-login| event.
+ */
+ void cancelLogin(in AString eventId);
+
+ /**
+ * Notify prepare phase is finished, routing and dns must be ready for sending
+ * out XMLHttpRequest. this is callback for CaptivePortalDetector API user.
+ * @param ifname The name of network interface, must be unique.
+ */
+ void finishPreparation(in AString ifname);
+};
diff --git a/toolkit/components/captivedetect/test/captive-portal-simulator.js b/toolkit/components/captivedetect/test/captive-portal-simulator.js
new file mode 100644
index 0000000000..3fee88bf5f
--- /dev/null
+++ b/toolkit/components/captivedetect/test/captive-portal-simulator.js
@@ -0,0 +1,76 @@
+/*
+ * This is a NodeJS script that crudely simulates a captive portal. It is
+ * intended for use by QA and engineering while working on the captive portal
+ * feature in Firefox.
+ *
+ * It maintains the authentication state (logged in or logged out). The root
+ * URL ("/") displays either a link to log out (if the state is logged in) or
+ * to log in (if the state is logged out). A canonical URL ("/test") is
+ * provided: when this URL is requested, if the state is logged in, a "success"
+ * response is sent. If the state is logged out, a redirect is sent (back to
+ * "/"). This script can be used to test Firefox's captive portal detection
+ * features by setting the captivedetect.canonicalURL pref in about:config to
+ * http://localhost:8080/test.
+ *
+ * Originally written by Nihanth.
+ *
+ * Run it like this:
+ *
+ * mach node toolkit/components/captivedetect/test/captive-portal-simulator.js
+ */
+
+// eslint-disable-next-line no-undef
+var http = require("http");
+
+const PORT = 8080;
+
+// Crude HTML for login and logout links
+// loggedOutLink is displayed when the state is logged out, and shows a link
+// that sets the state to logged in.
+const loggedOutLink = "<html><body><a href='/set'>login</a></body></html>";
+// loggedInLink is displayed when the state is logged in, and shows a link
+// that resets the state to logged out.
+const loggedInLink = "<html><body><a href='/reset'>logout</a></body></html>";
+
+// Our state constants
+const OUT = 0;
+const IN = 1;
+
+// State variable
+var state = OUT;
+
+function handleRequest(request, response) {
+ if (request.url == "/reset") {
+ // Set state to logged out and redirect to "/"
+ state = OUT;
+ response.writeHead(302, { location: "/" });
+ response.end();
+ } else if (request.url == "/set") {
+ // Set state to logged in and redirect to canonical URL
+ state = IN;
+ response.writeHead(302, { location: "/test" });
+ response.end();
+ } else if (request.url == "/test") {
+ // Canonical URL. Send canonical response if logged in, else
+ // redirect to index.
+ if (state == IN) {
+ response.setHeader("Content-Type", "text/html");
+ response.end(
+ `<meta http-equiv="refresh" content="0;url=https://support.mozilla.org/kb/captive-portal"/>`
+ );
+ return;
+ }
+ response.writeHead(302, { location: "/" });
+ response.end();
+ } else {
+ // Index: send a login or logout link based on state.
+ response.setHeader("Content-Type", "text/html");
+ response.end(state == IN ? loggedInLink : loggedOutLink);
+ }
+}
+
+// Start the server.
+var server = http.createServer(handleRequest);
+server.listen(PORT, function () {
+ console.log("Server listening on: http://localhost:%s", PORT);
+});
diff --git a/toolkit/components/captivedetect/test/unit/head_setprefs.js b/toolkit/components/captivedetect/test/unit/head_setprefs.js
new file mode 100644
index 0000000000..4e96a6ae46
--- /dev/null
+++ b/toolkit/components/captivedetect/test/unit/head_setprefs.js
@@ -0,0 +1,76 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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";
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var {
+ HTTP_400,
+ HTTP_401,
+ HTTP_402,
+ HTTP_403,
+ HTTP_404,
+ HTTP_405,
+ HTTP_406,
+ HTTP_407,
+ HTTP_408,
+ HTTP_409,
+ HTTP_410,
+ HTTP_411,
+ HTTP_412,
+ HTTP_413,
+ HTTP_414,
+ HTTP_415,
+ HTTP_417,
+ HTTP_500,
+ HTTP_501,
+ HTTP_502,
+ HTTP_503,
+ HTTP_504,
+ HTTP_505,
+ HttpError,
+ HttpServer,
+} = ChromeUtils.importESModule("resource://testing-common/httpd.sys.mjs");
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gCaptivePortalDetector",
+ "@mozilla.org/toolkit/captive-detector;1",
+ "nsICaptivePortalDetector"
+);
+
+const kCanonicalSitePath = "/canonicalSite.html";
+const kCanonicalSiteContent = "true";
+const kPrefsCanonicalURL = "captivedetect.canonicalURL";
+const kPrefsCanonicalContent = "captivedetect.canonicalContent";
+const kPrefsMaxWaitingTime = "captivedetect.maxWaitingTime";
+const kPrefsPollingTime = "captivedetect.pollingTime";
+
+var gServer;
+var gServerURL;
+
+function setupPrefs() {
+ Services.prefs.setCharPref(
+ kPrefsCanonicalURL,
+ gServerURL + kCanonicalSitePath
+ );
+ Services.prefs.setCharPref(kPrefsCanonicalContent, kCanonicalSiteContent);
+ Services.prefs.setIntPref(kPrefsMaxWaitingTime, 0);
+ Services.prefs.setIntPref(kPrefsPollingTime, 1);
+}
+
+function run_captivedetect_test(xhr_handler, fakeUIResponse, testfun) {
+ gServer = new HttpServer();
+ gServer.registerPathHandler(kCanonicalSitePath, xhr_handler);
+ gServer.start(-1);
+ gServerURL = "http://localhost:" + gServer.identity.primaryPort;
+
+ setupPrefs();
+
+ fakeUIResponse();
+
+ testfun();
+}
diff --git a/toolkit/components/captivedetect/test/unit/test_abort.js b/toolkit/components/captivedetect/test/unit/test_abort.js
new file mode 100644
index 0000000000..0e0a944f9b
--- /dev/null
+++ b/toolkit/components/captivedetect/test/unit/test_abort.js
@@ -0,0 +1,52 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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 kInterfaceName = "wifi";
+
+var step = 0;
+var loginFinished = false;
+
+function xhr_handler(metadata, response) {
+ response.setStatusLine(metadata.httpVersion, 200, "OK");
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/plain", false);
+ if (loginFinished) {
+ response.write("true");
+ } else {
+ response.write("false");
+ }
+}
+
+function fakeUIResponse() {
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login") {
+ do_throw("should not receive captive-portal-login event");
+ }
+ }, "captive-portal-login");
+}
+
+function test_abort() {
+ do_test_pending();
+
+ let callback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ Assert.equal(++step, 1);
+ gCaptivePortalDetector.finishPreparation(kInterfaceName);
+ },
+ complete: function complete(success) {
+ do_throw("should not execute |complete| callback");
+ },
+ };
+
+ gCaptivePortalDetector.checkCaptivePortal(kInterfaceName, callback);
+ gCaptivePortalDetector.abort(kInterfaceName);
+ gServer.stop(do_test_finished);
+}
+
+function run_test() {
+ run_captivedetect_test(xhr_handler, fakeUIResponse, test_abort);
+}
diff --git a/toolkit/components/captivedetect/test/unit/test_abort_during_user_login.js b/toolkit/components/captivedetect/test/unit/test_abort_during_user_login.js
new file mode 100644
index 0000000000..bd0011817d
--- /dev/null
+++ b/toolkit/components/captivedetect/test/unit/test_abort_during_user_login.js
@@ -0,0 +1,64 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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 kInterfaceName = "wifi";
+
+var step = 0;
+var loginFinished = false;
+
+function xhr_handler(metadata, response) {
+ response.setStatusLine(metadata.httpVersion, 200, "OK");
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/plain", false);
+ if (loginFinished) {
+ response.write("true");
+ } else {
+ response.write("false");
+ }
+}
+
+function fakeUIResponse() {
+ let requestId;
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login") {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", gServerURL + kCanonicalSitePath, true);
+ xhr.send();
+ loginFinished = true;
+ Assert.equal(++step, 2);
+ requestId = JSON.parse(data).id;
+ gCaptivePortalDetector.abort(kInterfaceName);
+ }
+ }, "captive-portal-login");
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login-abort") {
+ Assert.equal(++step, 3);
+ Assert.equal(JSON.parse(data).id, requestId);
+ gServer.stop(do_test_finished);
+ }
+ }, "captive-portal-login-abort");
+}
+
+function test_abort() {
+ do_test_pending();
+
+ let callback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ Assert.equal(++step, 1);
+ gCaptivePortalDetector.finishPreparation(kInterfaceName);
+ },
+ complete: function complete(success) {
+ do_throw("should not execute |complete| callback");
+ },
+ };
+
+ gCaptivePortalDetector.checkCaptivePortal(kInterfaceName, callback);
+}
+
+function run_test() {
+ run_captivedetect_test(xhr_handler, fakeUIResponse, test_abort);
+}
diff --git a/toolkit/components/captivedetect/test/unit/test_abort_ongoing_request.js b/toolkit/components/captivedetect/test/unit/test_abort_ongoing_request.js
new file mode 100644
index 0000000000..b209e02796
--- /dev/null
+++ b/toolkit/components/captivedetect/test/unit/test_abort_ongoing_request.js
@@ -0,0 +1,74 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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 kInterfaceName = "wifi";
+const kOtherInterfaceName = "ril";
+
+var step = 0;
+var loginFinished = false;
+
+function xhr_handler(metadata, response) {
+ response.setStatusLine(metadata.httpVersion, 200, "OK");
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/plain", false);
+ if (loginFinished) {
+ response.write("true");
+ } else {
+ response.write("false");
+ }
+}
+
+function fakeUIResponse() {
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login") {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", gServerURL + kCanonicalSitePath, true);
+ xhr.send();
+ loginFinished = true;
+ Assert.equal(++step, 3);
+ }
+ }, "captive-portal-login");
+}
+
+function test_multiple_requests_abort() {
+ do_test_pending();
+
+ let callback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ Assert.equal(++step, 1);
+ gCaptivePortalDetector.finishPreparation(kInterfaceName);
+ },
+ complete: function complete(success) {
+ do_throw("should not execute |complete| callback for " + kInterfaceName);
+ },
+ };
+
+ let otherCallback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ Assert.equal(++step, 2);
+ gCaptivePortalDetector.finishPreparation(kOtherInterfaceName);
+ },
+ complete: function complete(success) {
+ Assert.equal(++step, 4);
+ Assert.ok(success);
+ gServer.stop(do_test_finished);
+ },
+ };
+
+ gCaptivePortalDetector.checkCaptivePortal(kInterfaceName, callback);
+ gCaptivePortalDetector.checkCaptivePortal(kOtherInterfaceName, otherCallback);
+ gCaptivePortalDetector.abort(kInterfaceName);
+}
+
+function run_test() {
+ run_captivedetect_test(
+ xhr_handler,
+ fakeUIResponse,
+ test_multiple_requests_abort
+ );
+}
diff --git a/toolkit/components/captivedetect/test/unit/test_abort_pending_request.js b/toolkit/components/captivedetect/test/unit/test_abort_pending_request.js
new file mode 100644
index 0000000000..16d621a06b
--- /dev/null
+++ b/toolkit/components/captivedetect/test/unit/test_abort_pending_request.js
@@ -0,0 +1,71 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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 kInterfaceName = "wifi";
+const kOtherInterfaceName = "ril";
+
+var step = 0;
+var loginFinished = false;
+
+function xhr_handler(metadata, response) {
+ response.setStatusLine(metadata.httpVersion, 200, "OK");
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/plain", false);
+ if (loginFinished) {
+ response.write("true");
+ } else {
+ response.write("false");
+ }
+}
+
+function fakeUIResponse() {
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login") {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", gServerURL + kCanonicalSitePath, true);
+ xhr.send();
+ loginFinished = true;
+ Assert.equal(++step, 2);
+ }
+ }, "captive-portal-login");
+}
+
+function test_abort() {
+ do_test_pending();
+
+ let callback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ Assert.equal(++step, 1);
+ gCaptivePortalDetector.finishPreparation(kInterfaceName);
+ },
+ complete: function complete(success) {
+ Assert.equal(++step, 3);
+ Assert.ok(success);
+ gServer.stop(do_test_finished);
+ },
+ };
+
+ let otherCallback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ do_throw(
+ "should not execute |prepare| callback for " + kOtherInterfaceName
+ );
+ },
+ complete: function complete(success) {
+ do_throw("should not execute |complete| callback for " + kInterfaceName);
+ },
+ };
+
+ gCaptivePortalDetector.checkCaptivePortal(kInterfaceName, callback);
+ gCaptivePortalDetector.checkCaptivePortal(kOtherInterfaceName, otherCallback);
+ gCaptivePortalDetector.abort(kOtherInterfaceName);
+}
+
+function run_test() {
+ run_captivedetect_test(xhr_handler, fakeUIResponse, test_abort);
+}
diff --git a/toolkit/components/captivedetect/test/unit/test_captive_portal_found.js b/toolkit/components/captivedetect/test/unit/test_captive_portal_found.js
new file mode 100644
index 0000000000..2a9acb8ab0
--- /dev/null
+++ b/toolkit/components/captivedetect/test/unit/test_captive_portal_found.js
@@ -0,0 +1,65 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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 kInterfaceName = "wifi";
+
+var step = 0;
+var loginFinished = false;
+
+function xhr_handler(metadata, response) {
+ response.setStatusLine(metadata.httpVersion, 200, "OK");
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/plain", false);
+ if (loginFinished) {
+ response.write("true");
+ } else {
+ response.write("false");
+ }
+}
+
+function fakeUIResponse() {
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login") {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", gServerURL + kCanonicalSitePath, true);
+ xhr.send();
+ loginFinished = true;
+ Assert.equal(++step, 2);
+ }
+ }, "captive-portal-login");
+
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login-success") {
+ Assert.equal(++step, 4);
+ gServer.stop(do_test_finished);
+ }
+ }, "captive-portal-login-success");
+}
+
+function test_portal_found() {
+ do_test_pending();
+
+ let callback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ Assert.equal(++step, 1);
+ gCaptivePortalDetector.finishPreparation(kInterfaceName);
+ },
+ complete: function complete(success) {
+ // Since this is a synchronous callback, it must happen before
+ // 'captive-portal-login-success' is received.
+ // (Check captivedetect.js::executeCallback
+ Assert.equal(++step, 3);
+ Assert.ok(success);
+ },
+ };
+
+ gCaptivePortalDetector.checkCaptivePortal(kInterfaceName, callback);
+}
+
+function run_test() {
+ run_captivedetect_test(xhr_handler, fakeUIResponse, test_portal_found);
+}
diff --git a/toolkit/components/captivedetect/test/unit/test_captive_portal_found_303.js b/toolkit/components/captivedetect/test/unit/test_captive_portal_found_303.js
new file mode 100644
index 0000000000..1e4ec1f97d
--- /dev/null
+++ b/toolkit/components/captivedetect/test/unit/test_captive_portal_found_303.js
@@ -0,0 +1,74 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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 kInterfaceName = "wifi";
+
+var step = 0;
+var loginFinished = false;
+
+var gRedirectServer;
+var gRedirectServerURL;
+
+function xhr_handler(metadata, response) {
+ if (loginFinished) {
+ response.setStatusLine(metadata.httpVersion, 200, "OK");
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/plain", false);
+ response.write("true");
+ } else {
+ response.setStatusLine(metadata.httpVersion, 303, "See Other");
+ response.setHeader("Location", gRedirectServerURL, false);
+ response.setHeader("Content-Type", "text/html", false);
+ }
+}
+
+function fakeUIResponse() {
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login") {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", gServerURL + kCanonicalSitePath, true);
+ xhr.send();
+ loginFinished = true;
+ Assert.equal(++step, 2);
+ }
+ }, "captive-portal-login");
+
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login-success") {
+ Assert.equal(++step, 4);
+ gServer.stop(function () {
+ gRedirectServer.stop(do_test_finished);
+ });
+ }
+ }, "captive-portal-login-success");
+}
+
+function test_portal_found() {
+ do_test_pending();
+
+ let callback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ Assert.equal(++step, 1);
+ gCaptivePortalDetector.finishPreparation(kInterfaceName);
+ },
+ complete: function complete(success) {
+ Assert.equal(++step, 3);
+ Assert.ok(success);
+ },
+ };
+
+ gCaptivePortalDetector.checkCaptivePortal(kInterfaceName, callback);
+}
+
+function run_test() {
+ gRedirectServer = new HttpServer();
+ gRedirectServer.start(-1);
+ gRedirectServerURL =
+ "http://localhost:" + gRedirectServer.identity.primaryPort;
+
+ run_captivedetect_test(xhr_handler, fakeUIResponse, test_portal_found);
+}
diff --git a/toolkit/components/captivedetect/test/unit/test_captive_portal_not_found.js b/toolkit/components/captivedetect/test/unit/test_captive_portal_not_found.js
new file mode 100644
index 0000000000..25dd5f7c99
--- /dev/null
+++ b/toolkit/components/captivedetect/test/unit/test_captive_portal_not_found.js
@@ -0,0 +1,54 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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 kInterfaceName = "wifi";
+
+var step = 0;
+var attempt = 0;
+
+function xhr_handler(metadata, response) {
+ dump("HTTP activity\n");
+ response.setStatusLine(metadata.httpVersion, 200, "OK");
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/plain", false);
+ response.write("true");
+ attempt++;
+}
+
+function fakeUIResponse() {
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic == "captive-portal-login") {
+ do_throw("should not receive captive-portal-login event");
+ }
+ }, "captive-portal-login");
+}
+
+function test_portal_not_found() {
+ do_test_pending();
+
+ let callback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ Assert.equal(++step, 1);
+ gCaptivePortalDetector.finishPreparation(kInterfaceName);
+ },
+ complete: function complete(success) {
+ Assert.equal(++step, 2);
+ Assert.ok(success);
+ Assert.equal(attempt, 1);
+ gServer.stop(function () {
+ dump("server stop\n");
+ do_test_finished();
+ });
+ },
+ };
+
+ gCaptivePortalDetector.checkCaptivePortal(kInterfaceName, callback);
+}
+
+function run_test() {
+ run_captivedetect_test(xhr_handler, fakeUIResponse, test_portal_not_found);
+}
diff --git a/toolkit/components/captivedetect/test/unit/test_captive_portal_not_found_404.js b/toolkit/components/captivedetect/test/unit/test_captive_portal_not_found_404.js
new file mode 100644
index 0000000000..3f9d212a1f
--- /dev/null
+++ b/toolkit/components/captivedetect/test/unit/test_captive_portal_not_found_404.js
@@ -0,0 +1,47 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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 kInterfaceName = "wifi";
+
+var step = 0;
+var attempt = 0;
+
+function xhr_handler(metadata, response) {
+ response.setStatusLine(metadata.httpVersion, 404, "Page not Found");
+ attempt++;
+}
+
+function fakeUIResponse() {
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login") {
+ do_throw("should not receive captive-portal-login event");
+ }
+ }, "captive-portal-login");
+}
+
+function test_portal_not_found() {
+ do_test_pending();
+
+ let callback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ Assert.equal(++step, 1);
+ gCaptivePortalDetector.finishPreparation(kInterfaceName);
+ },
+ complete: function complete(success) {
+ Assert.equal(++step, 2);
+ Assert.ok(!success);
+ Assert.equal(attempt, 6);
+ gServer.stop(do_test_finished);
+ },
+ };
+
+ gCaptivePortalDetector.checkCaptivePortal(kInterfaceName, callback);
+}
+
+function run_test() {
+ run_captivedetect_test(xhr_handler, fakeUIResponse, test_portal_not_found);
+}
diff --git a/toolkit/components/captivedetect/test/unit/test_multiple_requests.js b/toolkit/components/captivedetect/test/unit/test_multiple_requests.js
new file mode 100644
index 0000000000..7778a595b0
--- /dev/null
+++ b/toolkit/components/captivedetect/test/unit/test_multiple_requests.js
@@ -0,0 +1,83 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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 kInterfaceName = "wifi";
+const kOtherInterfaceName = "ril";
+
+var step = 0;
+var loginFinished = false;
+var loginSuccessCount = 0;
+
+function xhr_handler(metadata, response) {
+ response.setStatusLine(metadata.httpVersion, 200, "OK");
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/plain", false);
+ if (loginFinished) {
+ response.write("true");
+ } else {
+ response.write("false");
+ }
+}
+
+function fakeUIResponse() {
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login") {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", gServerURL + kCanonicalSitePath, true);
+ xhr.send();
+ loginFinished = true;
+ Assert.equal(++step, 2);
+ }
+ }, "captive-portal-login");
+
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login-success") {
+ loginSuccessCount++;
+ if (loginSuccessCount > 1) {
+ throw new Error(
+ "We should only receive 'captive-portal-login-success' once"
+ );
+ }
+ Assert.equal(++step, 4);
+ }
+ }, "captive-portal-login-success");
+}
+
+function test_multiple_requests() {
+ do_test_pending();
+
+ let callback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ Assert.equal(++step, 1);
+ gCaptivePortalDetector.finishPreparation(kInterfaceName);
+ },
+ complete: function complete(success) {
+ Assert.equal(++step, 3);
+ Assert.ok(success);
+ },
+ };
+
+ let otherCallback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ Assert.equal(++step, 5);
+ gCaptivePortalDetector.finishPreparation(kOtherInterfaceName);
+ },
+ complete: function complete(success) {
+ Assert.equal(++step, 6);
+ Assert.ok(success);
+ gServer.stop(do_test_finished);
+ },
+ };
+
+ gCaptivePortalDetector.checkCaptivePortal(kInterfaceName, callback);
+ gCaptivePortalDetector.checkCaptivePortal(kOtherInterfaceName, otherCallback);
+}
+
+function run_test() {
+ run_captivedetect_test(xhr_handler, fakeUIResponse, test_multiple_requests);
+}
diff --git a/toolkit/components/captivedetect/test/unit/test_user_cancel.js b/toolkit/components/captivedetect/test/unit/test_user_cancel.js
new file mode 100644
index 0000000000..cceb507403
--- /dev/null
+++ b/toolkit/components/captivedetect/test/unit/test_user_cancel.js
@@ -0,0 +1,52 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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 kInterfaceName = "wifi";
+
+var step = 0;
+
+function xhr_handler(metadata, response) {
+ response.setStatusLine(metadata.httpVersion, 200, "OK");
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/plain", false);
+ response.write("false");
+}
+
+function fakeUIResponse() {
+ Services.obs.addObserver(function observe(subject, topic, data) {
+ if (topic === "captive-portal-login") {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", gServerURL + kCanonicalSitePath, true);
+ xhr.send();
+ Assert.equal(++step, 2);
+ let details = JSON.parse(data);
+ gCaptivePortalDetector.cancelLogin(details.id);
+ }
+ }, "captive-portal-login");
+}
+
+function test_cancel() {
+ do_test_pending();
+
+ let callback = {
+ QueryInterface: ChromeUtils.generateQI(["nsICaptivePortalCallback"]),
+ prepare: function prepare() {
+ Assert.equal(++step, 1);
+ gCaptivePortalDetector.finishPreparation(kInterfaceName);
+ },
+ complete: function complete(success) {
+ Assert.equal(++step, 3);
+ Assert.ok(!success);
+ gServer.stop(do_test_finished);
+ },
+ };
+
+ gCaptivePortalDetector.checkCaptivePortal(kInterfaceName, callback);
+}
+
+function run_test() {
+ run_captivedetect_test(xhr_handler, fakeUIResponse, test_cancel);
+}
diff --git a/toolkit/components/captivedetect/test/unit/xpcshell.toml b/toolkit/components/captivedetect/test/unit/xpcshell.toml
new file mode 100644
index 0000000000..2d03dce948
--- /dev/null
+++ b/toolkit/components/captivedetect/test/unit/xpcshell.toml
@@ -0,0 +1,23 @@
+[DEFAULT]
+head = "head_setprefs.js"
+
+["test_abort.js"]
+
+["test_abort_during_user_login.js"]
+
+["test_abort_ongoing_request.js"]
+
+["test_abort_pending_request.js"]
+
+
+["test_captive_portal_found.js"]
+
+["test_captive_portal_found_303.js"]
+
+["test_captive_portal_not_found.js"]
+
+["test_captive_portal_not_found_404.js"]
+
+["test_multiple_requests.js"]
+
+["test_user_cancel.js"]