Merge "Bring in tests against spice console" into mcp/caracal
diff --git a/requirements.txt b/requirements.txt
index c6dd58a..83410e2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -24,3 +24,4 @@
 defusedxml>=0.7.1 # PSFL
 fasteners>=0.16.0 # Apache-2.0
 tenacity>=4.4.0 # Apache-2.0
+websocket-client # LGPLv2+
diff --git a/tempest/api/compute/servers/test_console.py b/tempest/api/compute/servers/test_console.py
new file mode 100644
index 0000000..0cbeb41
--- /dev/null
+++ b/tempest/api/compute/servers/test_console.py
@@ -0,0 +1,327 @@
+# Copyright 2016-2017 OpenStack Foundation
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+import ssl
+import struct
+import urllib.parse as urlparse
+import urllib3
+import websocket
+
+from tempest.api.compute import base
+from tempest.common import compute
+from tempest import config
+from tempest.lib import decorators
+
+CONF = config.CONF
+
+
+class ConsoleTestBase(base.BaseV2ComputeTest):
+    create_default_network = True
+
+    def setUp(self):
+        super(ConsoleTestBase, self).setUp()
+        self._websocket = None
+
+    def tearDown(self):
+        super(ConsoleTestBase, self).tearDown()
+        if self._websocket is not None:
+            self._websocket.close()
+        # NOTE(zhufl): Because server_check_teardown will raise Exception
+        # which will prevent other cleanup steps from being executed, so
+        # server_check_teardown should be called after super's tearDown.
+        self.server_check_teardown()
+
+    @classmethod
+    def setup_clients(cls):
+        super(ConsoleTestBase, cls).setup_clients()
+        cls.client = cls.servers_client
+
+    @classmethod
+    def resource_setup(cls):
+        super(ConsoleTestBase, cls).resource_setup()
+        cls.server = cls.create_test_server(wait_until="ACTIVE")
+        cls.use_get_remote_console = False
+        if not cls.is_requested_microversion_compatible("2.5"):
+            cls.use_get_remote_console = True
+
+    @property
+    def cert_params(self):
+        ssl_opt = {}
+        if CONF.identity.disable_ssl_certificate_validation:
+            ssl_opt["cert_reqs"] = ssl.CERT_NONE
+        else:
+            ssl_opt["ca_certs"] = CONF.identity.ca_certificates_file
+        return ssl_opt
+
+    def _validate_html(self, url, js_title):
+        """Verify we can connect to console and get back the javascript."""
+
+        resp = urllib3.PoolManager(**self.cert_params).request("GET", url)
+        # Make sure that the GET request was accepted by the console proxy
+        self.assertEqual(
+            resp.status,
+            200,
+            "Got a Bad HTTP Response on the "
+            "initial call: " + str(resp.status),
+        )
+        # Do some basic validation to make sure it is an expected HTML document
+        resp_data = resp.data.decode()
+        # This is needed in the case of example: <html lang="en">
+        self.assertRegex(
+            resp_data, "<html.*>", "Not a valid html document in the response."
+        )
+        self.assertIn(
+            "</html>", resp_data, "Not a valid html document in the response."
+        )
+        # Just try to make sure we got JavaScript back for console, since we
+        # won't actually use it since not inside of a browser
+        self.assertIn(
+            js_title,
+            resp_data,
+            "Not a valid console javascript html document.",
+        )
+        self.assertIn(
+            "<script",
+            resp_data,
+            "Not a valid console javascript html document.",
+        )
+
+    def _validate_websocket_upgrade(self):
+        """Verify that the websocket upgrade was successful.
+
+        Parses response and ensures that required response
+        fields are present and accurate.
+        (https://tools.ietf.org/html/rfc7231#section-6.2.2)
+        """
+
+        self.assertTrue(
+            self._websocket.response.startswith(
+                b"HTTP/1.1 101 Switching Protocols"
+            ),
+            "Incorrect HTTP return status code: {}".format(
+                str(self._websocket.response)
+            ),
+        )
+        _required_header = "upgrade: websocket"
+        _response = str(self._websocket.response).lower()
+        self.assertIn(
+            _required_header,
+            _response,
+            "Did not get the expected WebSocket HTTP Response.",
+        )
+
+    def _get_console_body(self, type, protocol, get_console):
+        if self.use_get_remote_console:
+            return self.client.get_remote_console(
+                self.server["id"], type=type, protocol=protocol
+            )["remote_console"]
+        return getattr(self.client, get_console)(self.server["id"], type=type)[
+            "console"
+        ]
+
+    def _test_console_bad_token(self, type, protocol, get_console):
+        body = self._get_console_body(type, protocol, get_console)
+        self.assertEqual(type, body["type"])
+        # Do the WebSockify HTTP Request to console proxy with a bad token
+        parts = urlparse.urlparse(body["url"])
+        qparams = urlparse.parse_qs(parts.query)
+        if "path" in qparams:
+            qparams["path"] = urlparse.unquote(qparams["path"][0]).replace(
+                "token=", "token=bad"
+            )
+        elif "token" in qparams:
+            qparams["token"] = "bad" + qparams["token"][0]
+        new_query = urlparse.urlencode(qparams)
+        new_parts = urlparse.ParseResult(
+            parts.scheme,
+            parts.netloc,
+            parts.path,
+            parts.params,
+            new_query,
+            parts.fragment,
+        )
+        url = urlparse.urlunparse(new_parts)
+        self._websocket = compute.create_websocket(url)
+        # Make sure the console proxy rejected the connection and closed it
+        data = self._websocket.receive_frame()
+        self.assertTrue(
+            data is None or not data,
+            "The console proxy actually sent us some data, but we "
+            "expected it to close the connection.",
+        )
+
+
+class NoVNCConsoleTestJSON(ConsoleTestBase):
+    """Test novnc console"""
+
+    @classmethod
+    def skip_checks(cls):
+        super(NoVNCConsoleTestJSON, cls).skip_checks()
+        if not CONF.compute_feature_enabled.vnc_console:
+            raise cls.skipException("VNC Console feature is disabled.")
+
+    def _validate_rfb_negotiation(self):
+        """Verify we can connect to novnc and do the websocket connection."""
+        # Turn the Socket into a WebSocket to do the communication
+        data = self._websocket.receive_frame()
+        self.assertFalse(
+            data is None or not data,
+            "Token must be invalid because the connection closed.",
+        )
+        # Parse the RFB version from the data to make sure it is valid
+        # and belong to the known supported RFB versions.
+        version = float(
+            "%d.%d" % (int(data[4:7], base=10), int(data[8:11], base=10))
+        )
+        # Add the max RFB versions supported
+        supported_versions = [3.3, 3.8]
+        self.assertIn(
+            version, supported_versions, "Bad RFB Version: " + str(version)
+        )
+        # Send our RFB version to the server
+        self._websocket.send_frame(data)
+        # Get the sever authentication type and make sure None is supported
+        data = self._websocket.receive_frame()
+        self.assertIsNotNone(data, "Expected authentication type None.")
+        data_length = len(data)
+        if version == 3.3:
+            # For RFB 3.3: in the security handshake, rather than a two-way
+            # negotiation, the server decides the security type and sends a
+            # single word(4 bytes).
+            self.assertEqual(
+                data_length, 4, "Expected authentication type None."
+            )
+            self.assertIn(
+                1,
+                [int(data[i]) for i in (0, 3)],
+                "Expected authentication type None.",
+            )
+        else:
+            self.assertGreaterEqual(
+                len(data), 2, "Expected authentication type None."
+            )
+            self.assertIn(
+                1,
+                [int(data[i + 1]) for i in range(int(data[0]))],
+                "Expected authentication type None.",
+            )
+            # Send to the server that we only support authentication
+            # type None
+            self._websocket.send_frame(bytes((1,)))
+
+            # The server should send 4 bytes of 0's if security
+            # handshake succeeded
+            data = self._websocket.receive_frame()
+            self.assertEqual(
+                len(data), 4, "Server did not think security was successful."
+            )
+            self.assertEqual(
+                [int(i) for i in data],
+                [0, 0, 0, 0],
+                "Server did not think security was successful.",
+            )
+
+        # Say to leave the desktop as shared as part of client initialization
+        self._websocket.send_frame(bytes((1,)))
+        # Get the server initialization packet back and make sure it is the
+        # right structure where bytes 20-24 is the name length and
+        # 24-N is the name
+        data = self._websocket.receive_frame()
+        data_length = len(data) if data is not None else 0
+        self.assertFalse(
+            data_length <= 24 or
+            data_length != (struct.unpack(">L", data[20:24])[0] + 24),
+            "Server initialization was not the right format.",
+        )
+        # Since the rest of the data on the screen is arbitrary, we will
+        # close the socket and end our validation of the data at this point
+        # Assert that the latest check was false, meaning that the server
+        # initialization was the right format
+        self.assertFalse(
+            data_length <= 24 or
+            data_length != (struct.unpack(">L", data[20:24])[0] + 24)
+        )
+
+    @decorators.idempotent_id("c640fdff-8ab4-45a4-a5d8-7e6146cbd0dc")
+    def test_novnc(self):
+        """Test accessing novnc console of server"""
+        body = self._get_console_body("novnc", "vnc", "get_vnc_console")
+        self.assertEqual("novnc", body["type"])
+        # Do the initial HTTP Request to novncproxy to get the JavaScript
+        self._validate_html(body["url"], "noVNC")
+        # Do the WebSockify HTTP Request to novncproxy to do the RFB connection
+        self._websocket = compute.create_websocket(body["url"])
+        # Validate that we successfully connected and upgraded to Web Sockets
+        self._validate_websocket_upgrade()
+        # Validate the RFB Negotiation to determine if a valid VNC session
+        self._validate_rfb_negotiation()
+
+    @decorators.idempotent_id("f9c79937-addc-4aaa-9e0e-841eef02aeb7")
+    def test_novnc_bad_token(self):
+        """Test accessing novnc console with bad token
+
+        Do the WebSockify HTTP Request to novnc proxy with a bad token,
+        the novnc proxy should reject the connection and closed it.
+        """
+        self._test_console_bad_token("novnc", "vnc", "get_vnc_console")
+
+
+class SpiceConsoleTestJSON(ConsoleTestBase):
+    """Test spice console"""
+
+    @classmethod
+    def skip_checks(cls):
+        super(SpiceConsoleTestJSON, cls).skip_checks()
+        if not CONF.compute_feature_enabled.spice_console:
+            raise cls.skipException("SPICE Console feature is disabled.")
+
+    def _validate_websocket_connection(self, body):
+        # Protocol Magic number UINT8[4] { 0x52, 0x45, 0x44, 0x51} // "REDQ"
+        spice_magic = b"REDQ"
+        scheme = {"https": "wss", "http": "ws"}
+
+        q = urlparse.urlparse(body["url"])
+        ws = websocket.WebSocket(sslopt=self.cert_params)
+        ws.connect(
+            f"{scheme[q.scheme]}://{q.netloc}/websockify", cookie=q.query,
+            subprotocols=["binary"]
+        )
+        ws.send_binary(b"\r\n\r\n")
+        opcode, data = ws.recv_data()
+        self.assertEqual(opcode, websocket.ABNF.OPCODE_BINARY)
+        self.assertTrue(data.startswith(spice_magic))
+
+    @decorators.idempotent_id("0914a681-72dd-4fad-8457-b45195373d3d")
+    def test_spice(self):
+        """Test accessing spice console of server"""
+        body = self._get_console_body(
+            "spice-html5", "spice", "get_spice_console"
+        )
+        self.assertEqual("spice-html5", body["type"])
+        # Do the initial HTTP Request to spiceproxy to get the JavaScript
+        self._validate_html(body["url"], "Spice Javascript client")
+        # Validate that we successfully connected to Web Sockets
+        self._validate_websocket_connection(body)
+
+    @decorators.idempotent_id("6f4b0690-d078-4a28-a2ce-33dafdfca7ac")
+    def test_spice_bad_token(self):
+        """Test accessing spice console with bad token
+
+        Do the WebSockify HTTP Request to spice proxy with a bad token,
+        the spice proxy should reject the connection and closed it.
+        """
+        self._test_console_bad_token(
+            "spice-html5", "spice", "get_spice_console"
+        )
diff --git a/tempest/api/compute/servers/test_novnc.py b/tempest/api/compute/servers/test_novnc.py
deleted file mode 100644
index c90aea8..0000000
--- a/tempest/api/compute/servers/test_novnc.py
+++ /dev/null
@@ -1,238 +0,0 @@
-# Copyright 2016-2017 OpenStack Foundation
-# All Rights Reserved.
-#
-#    Licensed under the Apache License, Version 2.0 (the "License"); you may
-#    not use this file except in compliance with the License. You may obtain
-#    a copy of the License at
-#
-#         http://www.apache.org/licenses/LICENSE-2.0
-#
-#    Unless required by applicable law or agreed to in writing, software
-#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-#    License for the specific language governing permissions and limitations
-#    under the License.
-
-import struct
-import urllib.parse as urlparse
-import urllib3
-
-from tempest.api.compute import base
-from tempest.common import compute
-from tempest import config
-from tempest.lib import decorators
-
-CONF = config.CONF
-
-
-class NoVNCConsoleTestJSON(base.BaseV2ComputeTest):
-    """Test novnc console"""
-
-    create_default_network = True
-
-    @classmethod
-    def skip_checks(cls):
-        super(NoVNCConsoleTestJSON, cls).skip_checks()
-        if not CONF.compute_feature_enabled.vnc_console:
-            raise cls.skipException('VNC Console feature is disabled.')
-
-    def setUp(self):
-        super(NoVNCConsoleTestJSON, self).setUp()
-        self._websocket = None
-
-    def tearDown(self):
-        super(NoVNCConsoleTestJSON, self).tearDown()
-        if self._websocket is not None:
-            self._websocket.close()
-        # NOTE(zhufl): Because server_check_teardown will raise Exception
-        # which will prevent other cleanup steps from being executed, so
-        # server_check_teardown should be called after super's tearDown.
-        self.server_check_teardown()
-
-    @classmethod
-    def setup_clients(cls):
-        super(NoVNCConsoleTestJSON, cls).setup_clients()
-        cls.client = cls.servers_client
-
-    @classmethod
-    def resource_setup(cls):
-        super(NoVNCConsoleTestJSON, cls).resource_setup()
-        cls.server = cls.create_test_server(wait_until="ACTIVE")
-        cls.use_get_remote_console = False
-        if not cls.is_requested_microversion_compatible('2.5'):
-            cls.use_get_remote_console = True
-
-    def _validate_novnc_html(self, vnc_url):
-        """Verify we can connect to novnc and get back the javascript."""
-        cert_params = {}
-
-        if CONF.identity.disable_ssl_certificate_validation:
-            cert_params['cert_reqs'] = "CERT_NONE"
-        else:
-            cert_params["cert_reqs"] = "CERT_REQUIRED"
-            cert_params["ca_certs"] = CONF.identity.ca_certificates_file
-
-        resp = urllib3.PoolManager(**cert_params).request('GET', vnc_url)
-        # Make sure that the GET request was accepted by the novncproxy
-        self.assertEqual(resp.status, 200, 'Got a Bad HTTP Response on the '
-                         'initial call: ' + str(resp.status))
-        # Do some basic validation to make sure it is an expected HTML document
-        resp_data = resp.data.decode()
-        # This is needed in the case of example: <html lang="en">
-        self.assertRegex(resp_data, '<html.*>',
-                         'Not a valid html document in the response.')
-        self.assertIn('</html>', resp_data,
-                      'Not a valid html document in the response.')
-        # Just try to make sure we got JavaScript back for noVNC, since we
-        # won't actually use it since not inside of a browser
-        self.assertIn('noVNC', resp_data,
-                      'Not a valid noVNC javascript html document.')
-        self.assertIn('<script', resp_data,
-                      'Not a valid noVNC javascript html document.')
-
-    def _validate_rfb_negotiation(self):
-        """Verify we can connect to novnc and do the websocket connection."""
-        # Turn the Socket into a WebSocket to do the communication
-        data = self._websocket.receive_frame()
-        self.assertFalse(data is None or not data,
-                         'Token must be invalid because the connection '
-                         'closed.')
-        # Parse the RFB version from the data to make sure it is valid
-        # and belong to the known supported RFB versions.
-        version = float("%d.%d" % (int(data[4:7], base=10),
-                                   int(data[8:11], base=10)))
-        # Add the max RFB versions supported
-        supported_versions = [3.3, 3.8]
-        self.assertIn(version, supported_versions,
-                      'Bad RFB Version: ' + str(version))
-        # Send our RFB version to the server
-        self._websocket.send_frame(data)
-        # Get the sever authentication type and make sure None is supported
-        data = self._websocket.receive_frame()
-        self.assertIsNotNone(data, 'Expected authentication type None.')
-        data_length = len(data)
-        if version == 3.3:
-            # For RFB 3.3: in the security handshake, rather than a two-way
-            # negotiation, the server decides the security type and sends a
-            # single word(4 bytes).
-            self.assertEqual(
-                data_length, 4, 'Expected authentication type None.')
-            self.assertIn(1, [int(data[i]) for i in (0, 3)],
-                          'Expected authentication type None.')
-        else:
-            self.assertGreaterEqual(
-                len(data), 2, 'Expected authentication type None.')
-            self.assertIn(
-                1,
-                [int(data[i + 1]) for i in range(int(data[0]))],
-                'Expected authentication type None.')
-            # Send to the server that we only support authentication
-            # type None
-            self._websocket.send_frame(bytes((1,)))
-
-            # The server should send 4 bytes of 0's if security
-            # handshake succeeded
-            data = self._websocket.receive_frame()
-            self.assertEqual(
-                len(data), 4,
-                'Server did not think security was successful.')
-            self.assertEqual(
-                [int(i) for i in data], [0, 0, 0, 0],
-                'Server did not think security was successful.')
-
-        # Say to leave the desktop as shared as part of client initialization
-        self._websocket.send_frame(bytes((1,)))
-        # Get the server initialization packet back and make sure it is the
-        # right structure where bytes 20-24 is the name length and
-        # 24-N is the name
-        data = self._websocket.receive_frame()
-        data_length = len(data) if data is not None else 0
-        self.assertFalse(data_length <= 24 or
-                         data_length != (struct.unpack(">L",
-                                                       data[20:24])[0] + 24),
-                         'Server initialization was not the right format.')
-        # Since the rest of the data on the screen is arbitrary, we will
-        # close the socket and end our validation of the data at this point
-        # Assert that the latest check was false, meaning that the server
-        # initialization was the right format
-        self.assertFalse(data_length <= 24 or
-                         data_length != (struct.unpack(">L",
-                                                       data[20:24])[0] + 24))
-
-    def _validate_websocket_upgrade(self):
-        """Verify that the websocket upgrade was successful.
-
-        Parses response and ensures that required response
-        fields are present and accurate.
-        (https://tools.ietf.org/html/rfc7231#section-6.2.2)
-        """
-
-        self.assertTrue(
-            self._websocket.response.startswith(b'HTTP/1.1 101 Switching '
-                                                b'Protocols'),
-            'Incorrect HTTP return status code: {}'.format(
-                str(self._websocket.response)
-            )
-        )
-        _required_header = 'upgrade: websocket'
-        _response = str(self._websocket.response).lower()
-        self.assertIn(
-            _required_header,
-            _response,
-            'Did not get the expected WebSocket HTTP Response.'
-        )
-
-    @decorators.idempotent_id('c640fdff-8ab4-45a4-a5d8-7e6146cbd0dc')
-    def test_novnc(self):
-        """Test accessing novnc console of server"""
-        if self.use_get_remote_console:
-            body = self.client.get_remote_console(
-                self.server['id'], console_type='novnc',
-                protocol='vnc')['remote_console']
-        else:
-            body = self.client.get_vnc_console(self.server['id'],
-                                               type='novnc')['console']
-        self.assertEqual('novnc', body['type'])
-        # Do the initial HTTP Request to novncproxy to get the NoVNC JavaScript
-        self._validate_novnc_html(body['url'])
-        # Do the WebSockify HTTP Request to novncproxy to do the RFB connection
-        self._websocket = compute.create_websocket(body['url'])
-        # Validate that we successfully connected and upgraded to Web Sockets
-        self._validate_websocket_upgrade()
-        # Validate the RFB Negotiation to determine if a valid VNC session
-        self._validate_rfb_negotiation()
-
-    @decorators.idempotent_id('f9c79937-addc-4aaa-9e0e-841eef02aeb7')
-    def test_novnc_bad_token(self):
-        """Test accessing novnc console with bad token
-
-        Do the WebSockify HTTP Request to novnc proxy with a bad token,
-        the novnc proxy should reject the connection and closed it.
-        """
-        if self.use_get_remote_console:
-            body = self.client.get_remote_console(
-                self.server['id'], console_type='novnc',
-                protocol='vnc')['remote_console']
-        else:
-            body = self.client.get_vnc_console(self.server['id'],
-                                               type='novnc')['console']
-        self.assertEqual('novnc', body['type'])
-        # Do the WebSockify HTTP Request to novncproxy with a bad token
-        parts = urlparse.urlparse(body['url'])
-        qparams = urlparse.parse_qs(parts.query)
-        if 'path' in qparams:
-            qparams['path'] = urlparse.unquote(qparams['path'][0]).replace(
-                'token=', 'token=bad')
-        elif 'token' in qparams:
-            qparams['token'] = 'bad' + qparams['token'][0]
-        new_query = urlparse.urlencode(qparams)
-        new_parts = urlparse.ParseResult(parts.scheme, parts.netloc,
-                                         parts.path, parts.params, new_query,
-                                         parts.fragment)
-        url = urlparse.urlunparse(new_parts)
-        self._websocket = compute.create_websocket(url)
-        # Make sure the novncproxy rejected the connection and closed it
-        data = self._websocket.receive_frame()
-        self.assertTrue(data is None or not data,
-                        "The novnc proxy actually sent us some data, but we "
-                        "expected it to close the connection.")
diff --git a/tempest/lib/api_schema/response/compute/v2_1/servers.py b/tempest/lib/api_schema/response/compute/v2_1/servers.py
index 14e2d3b..e066f7b 100644
--- a/tempest/lib/api_schema/response/compute/v2_1/servers.py
+++ b/tempest/lib/api_schema/response/compute/v2_1/servers.py
@@ -425,6 +425,8 @@
     }
 }
 
+get_spice_console = get_vnc_console
+
 get_console_output = {
     'status_code': [200],
     'response_body': {
diff --git a/tempest/lib/services/compute/servers_client.py b/tempest/lib/services/compute/servers_client.py
index 274e62d..27834da 100644
--- a/tempest/lib/services/compute/servers_client.py
+++ b/tempest/lib/services/compute/servers_client.py
@@ -787,6 +787,16 @@
         return self.action(server_id, "os-getVNCConsole",
                            schema.get_vnc_console, **kwargs)
 
+    def get_spice_console(self, server_id, **kwargs):
+        """Get URL of SPICE console.
+
+        For a full list of available parameters, please refer to the official
+        API reference:
+        https://docs.openstack.org/api-ref/compute/#get-spice-console-os-getspiceconsole-action-deprecated
+        """
+        return self.action(server_id, "os-getSPICEConsole",
+                           schema.get_spice_console, **kwargs)
+
     def add_fixed_ip(self, server_id, **kwargs):
         """Add a fixed IP to server instance.