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

// Verifies that a secure WebSocket honors the port parameter from an HTTPS RR
// (Bug 2023355) when the connection is driven by Happy Eyeballs. The WebSocket
// URL uses the default port (443) but the HTTPS RR advertises the real server
// port, so the connection can only succeed if the HTTPS RR port is used. A
// real (echo) WebSocket server is used so we exercise actual data flow over the
// HTTPS-RR-routed connection, not just the opening handshake.
//
// WebSocket upgrades normally disable HTTPS RR (NS_HTTP_STICKY_CONNECTION
// breaks the restart-based HTTPS RR fallback). Happy Eyeballs races endpoints
// instead of restarting, so it can honor the HTTPS RR; this test exercises
// that path.

"use strict";

/* import-globals-from head_trr.js */
/* import-globals-from head_websocket.js */

const { NodeWebSocketHttp2Server } = ChromeUtils.importESModule(
  "resource://testing-common/NodeServer.sys.mjs"
);

add_setup(async function setup() {
  trr_test_setup();

  Services.prefs.setBoolPref("network.http.http2.websockets", true);
  Services.prefs.setBoolPref("network.http.happy_eyeballs_enabled", true);
  Services.prefs.setBoolPref(
    "network.http.happy_eyeballs_upgrade_enabled",
    true
  );
  // HTTPS RR must be allowed as an alt-svc source for the port to be honored.
  Services.prefs.setBoolPref("network.dns.use_https_rr_as_altsvc", true);

  registerCleanupFunction(async () => {
    trr_clear_prefs();
    Services.prefs.clearUserPref("network.http.http2.websockets");
    Services.prefs.clearUserPref("network.http.happy_eyeballs_enabled");
    Services.prefs.clearUserPref("network.http.happy_eyeballs_upgrade_enabled");
    Services.prefs.clearUserPref("network.dns.use_https_rr_as_altsvc");
  });
});

// Opens a WebSocket, sends one message and resolves with the echoed reply.
function echoWebSocket(url, msg) {
  let chan = Cc["@mozilla.org/network/protocol;1?name=wss"]
    .getService(Ci.nsIWebSocketProtocolHandler)
    .newWebSocketChannel();
  // Use a content principal (as a real page-initiated WebSocket would). A
  // system principal on a non-document load disables HTTPS RR in BeginConnect.
  let principal = Services.scriptSecurityManager.createContentPrincipal(
    Services.io.newURI("https://alt1.example.com"),
    {}
  );
  chan.initLoadInfo(
    null, // aLoadingNode
    principal,
    principal,
    Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
    Ci.nsIContentPolicy.TYPE_WEBSOCKET
  );

  let uri = Services.io.newURI(url);
  return new Promise((resolve, reject) => {
    let onMessage = aMsg => {
      chan.close(Ci.nsIWebSocketChannel.CLOSE_NORMAL, "");
      resolve(aMsg);
    };
    let listener = {
      QueryInterface: ChromeUtils.generateQI(["nsIWebSocketListener"]),
      onAcknowledge() {},
      // The `ws` library echoes the data back as a binary frame.
      onBinaryMessageAvailable(aContext, aMsg) {
        onMessage(aMsg);
      },
      onMessageAvailable(aContext, aMsg) {
        onMessage(aMsg);
      },
      onServerClose() {},
      onStart() {
        chan.sendMsg(msg);
      },
      onStop(aContext, aStatusCode) {
        // Only meaningful if we never received a message (handshake or
        // connection failed); a successful run resolves in onMessageAvailable.
        reject(aStatusCode);
      },
    };
    chan.asyncOpen(uri, url, {}, 0, listener, null);
  });
}

add_task(async function test_wss_uses_https_rr_port() {
  // A real WebSocket-over-HTTP/2 echo server on its own (non-443) port.
  let wss = new NodeWebSocketHttp2Server();
  await wss.start();
  registerCleanupFunction(async () => wss.stop());
  Assert.notEqual(wss.port(), null);
  await wss.registerMessageHandler((data, ws) => {
    ws.send(data);
  });

  let trrServer = new TRRServer();
  await trrServer.start();
  registerCleanupFunction(async () => trrServer.stop());

  Services.prefs.setIntPref("network.trr.mode", 3);
  Services.prefs.setCharPref(
    "network.trr.uri",
    `https://foo.example.com:${trrServer.port()}/dns-query`
  );

  // Resolve alt1.example.com to localhost and advertise the real WebSocket
  // server port via the HTTPS RR. The wss:// URL below uses the default port.
  await trrServer.registerDoHAnswers("alt1.example.com", "A", {
    answers: [
      {
        name: "alt1.example.com",
        ttl: 55,
        type: "A",
        flush: false,
        data: "127.0.0.1",
      },
    ],
  });
  await trrServer.registerDoHAnswers("alt1.example.com", "HTTPS", {
    answers: [
      {
        name: "alt1.example.com",
        ttl: 55,
        type: "HTTPS",
        flush: false,
        data: {
          priority: 1,
          name: "alt1.example.com",
          values: [
            { key: "alpn", value: ["h2"] },
            { key: "port", value: wss.port() },
            { key: "ipv4hint", value: ["127.0.0.1"] },
          ],
        },
      },
    ],
  });

  Services.dns.clearCache(true);

  // Sanity check that the TRR server serves the HTTPS RR, so a connection
  // failure below is attributable to the port not being honored rather than a
  // missing record.
  let { inRecord } = await new TRRDNSListener("alt1.example.com", {
    type: Ci.nsIDNSService.RESOLVE_TYPE_HTTPSSVC,
  });
  let records = inRecord.QueryInterface(Ci.nsIDNSHTTPSSVCRecord).records;
  Assert.equal(records.length, 1, "got the HTTPS RR");

  // Connect to the default wss port (443). With the HTTPS RR port honored the
  // connection lands on the real echo server; otherwise it would try 443 and
  // fail.
  const msg = "happy eyeballs https rr websocket";
  let echoed = await echoWebSocket("wss://alt1.example.com/", msg);
  Assert.equal(echoed, msg, "echoed message matches (connected via HTTPS RR)");

  await wss.stop();
  await trrServer.stop();
});
