|
16 | 16 | from unittest.mock import MagicMock, patch |
17 | 17 |
|
18 | 18 | from . import ws_client as ws_client_module |
19 | | -from .ws_client import get_websocket_url, WSClient, V5_CHANNEL_PROTOCOL, V4_CHANNEL_PROTOCOL, CLOSE_CHANNEL, STDIN_CHANNEL |
| 19 | +from .ws_client import get_websocket_url, WSClient, V5_CHANNEL_PROTOCOL, V4_CHANNEL_PROTOCOL, CLOSE_CHANNEL, STDIN_CHANNEL, STDOUT_CHANNEL |
20 | 20 | from .ws_client import websocket_proxycare |
21 | 21 | from kubernetes.client.configuration import Configuration |
22 | 22 | import os |
23 | 23 | import socket |
| 24 | +import ssl |
24 | 25 | import threading |
25 | 26 | import pytest |
26 | 27 | from kubernetes import stream, client, config |
@@ -385,6 +386,108 @@ def test_readline_channel_returns_empty_bytes_on_expired_timeout(self): |
385 | 386 | self.assertEqual(line, b"") |
386 | 387 |
|
387 | 388 |
|
| 389 | +class WSClientUpdateTest(unittest.TestCase): |
| 390 | + """Tests for WSClient.update() frame consumption (issue #2375)""" |
| 391 | + |
| 392 | + def setUp(self): |
| 393 | + # Mock configuration to avoid real connections in WSClient.__init__ |
| 394 | + self.config_mock = MagicMock() |
| 395 | + self.config_mock.assert_hostname = False |
| 396 | + self.config_mock.api_key = {} |
| 397 | + self.config_mock.proxy = None |
| 398 | + self.config_mock.ssl_ca_cert = None |
| 399 | + self.config_mock.cert_file = None |
| 400 | + self.config_mock.key_file = None |
| 401 | + self.config_mock.verify_ssl = True |
| 402 | + |
| 403 | + def _make_client(self, mock_ws): |
| 404 | + with patch.object(ws_client_module, 'create_websocket') as mock_create: |
| 405 | + mock_create.return_value = mock_ws |
| 406 | + return WSClient(self.config_mock, "wss://test", headers=None, |
| 407 | + capture_all=True, binary=True) |
| 408 | + |
| 409 | + def test_update_reads_frames_pending_in_ssl_buffer(self): |
| 410 | + """Verify update reads a frame buffered inside the SSL socket even |
| 411 | + when the underlying socket does not report as readable. |
| 412 | +
|
| 413 | + SSL sockets decrypt a whole TLS record at a time, so frames that |
| 414 | + share a TLS record with a previously read frame sit decrypted in |
| 415 | + the SSLSocket's buffer where select()/poll() cannot see them.""" |
| 416 | + with patch('select.poll') as mock_poll, \ |
| 417 | + patch('select.select') as mock_select: |
| 418 | + # Nothing is readable on the underlying socket. |
| 419 | + mock_poll.return_value.poll.return_value = [] |
| 420 | + mock_select.return_value = ([], [], []) |
| 421 | + |
| 422 | + mock_ws = MagicMock() |
| 423 | + mock_ws.subprotocol = V4_CHANNEL_PROTOCOL |
| 424 | + mock_ws.connected = True |
| 425 | + # A decrypted frame is waiting inside the SSL socket. |
| 426 | + mock_ws.sock = MagicMock(spec=ssl.SSLSocket) |
| 427 | + mock_ws.sock.pending.return_value = 6 |
| 428 | + frame = MagicMock() |
| 429 | + frame.data = bytes([STDOUT_CHANNEL]) + b'hello' |
| 430 | + mock_ws.recv_data_frame.return_value = (websocket.ABNF.OPCODE_BINARY, frame) |
| 431 | + |
| 432 | + client = self._make_client(mock_ws) |
| 433 | + client.update(timeout=0) |
| 434 | + |
| 435 | + self.assertEqual(client._channels.get(STDOUT_CHANNEL), b'hello') |
| 436 | + |
| 437 | + def test_update_polls_when_no_ssl_data_pending(self): |
| 438 | + """Verify update falls back to poll/select when the SSL socket has no |
| 439 | + buffered data""" |
| 440 | + with patch('select.poll') as mock_poll, \ |
| 441 | + patch('select.select') as mock_select: |
| 442 | + mock_poll.return_value.poll.return_value = [] |
| 443 | + mock_select.return_value = ([], [], []) |
| 444 | + |
| 445 | + mock_ws = MagicMock() |
| 446 | + mock_ws.subprotocol = V4_CHANNEL_PROTOCOL |
| 447 | + mock_ws.connected = True |
| 448 | + mock_ws.sock = MagicMock(spec=ssl.SSLSocket) |
| 449 | + mock_ws.sock.pending.return_value = 0 |
| 450 | + |
| 451 | + client = self._make_client(mock_ws) |
| 452 | + client.update(timeout=0) |
| 453 | + |
| 454 | + mock_ws.recv_data_frame.assert_not_called() |
| 455 | + |
| 456 | + def test_update_receives_fragmented_message(self): |
| 457 | + """Verify a message fragmented into continuation frames is delivered |
| 458 | + in full. |
| 459 | +
|
| 460 | + websocket-client reassembles continuation (OPCODE_CONT) frames inside |
| 461 | + recv_data_frame() and returns the opcode of the initial frame, so |
| 462 | + update() must buffer the complete message.""" |
| 463 | + mock_ws = MagicMock() |
| 464 | + mock_ws.subprotocol = V4_CHANNEL_PROTOCOL |
| 465 | + mock_ws.connected = True |
| 466 | + client = self._make_client(mock_ws) |
| 467 | + |
| 468 | + server_sock, client_sock = socket.socketpair() |
| 469 | + try: |
| 470 | + ws = websocket.WebSocket() |
| 471 | + ws.sock = client_sock |
| 472 | + ws.connected = True |
| 473 | + client.sock = ws |
| 474 | + |
| 475 | + # A stdout message fragmented into an initial data frame (fin=0) |
| 476 | + # and a continuation frame (fin=1); server frames are unmasked. |
| 477 | + initial = websocket.ABNF(0, 0, 0, 0, websocket.ABNF.OPCODE_BINARY, |
| 478 | + 0, bytes([STDOUT_CHANNEL]) + b'A' * 10) |
| 479 | + cont = websocket.ABNF(1, 0, 0, 0, websocket.ABNF.OPCODE_CONT, |
| 480 | + 0, b'B' * 10) |
| 481 | + server_sock.sendall(initial.format() + cont.format()) |
| 482 | + |
| 483 | + client.update(timeout=5) |
| 484 | + |
| 485 | + self.assertEqual(client._channels.get(STDOUT_CHANNEL), |
| 486 | + b'A' * 10 + b'B' * 10) |
| 487 | + finally: |
| 488 | + server_sock.close() |
| 489 | + client_sock.close() |
| 490 | + |
388 | 491 |
|
389 | 492 | @pytest.fixture(scope="module") |
390 | 493 | def dummy_proxy(): |
|
0 commit comments