main
py 391 lines 13.2 KB
Raw
1 # Copyright 2026 Google LLC
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 """Real-wire contract tests for `colab ssh`.
16
17 Why this file exists: the mocked suite in tests/test_ssh.py mocks the websocket
18 at the boundary (`_connect_websocket`, `websocket.WebSocket.connect`, or
19 `_bridge_proxy_mode`), so the one line that actually puts bytes on the wire --
20
21 ws.connect(url, header=[f"{_PUBKEY_HEADER}: {pubkey}"]) # ssh.py
22
23 -- never runs under test. A wrong `_SSH_PATH` (e.g. the stale `/api/colab/ssh`)
24 or a wrong `_PUBKEY_HEADER` would pass every mocked test while breaking every
25 real connection. These tests close that gap: they stand up a real loopback
26 WebSocket server, drive the client's real connect path (`_build_ws_url` +
27 `_connect_websocket`, no mocks), and assert on the bytes the server actually
28 received -- the request path, the runtime-proxy-token query param, and the
29 pubkey header name+value. The server contract mirrors the google3 backend
30 (third_party/colab/sources/{server.ts,websocket_to_ssh.ts}): route
31 `/colab/ssh`, header `x-colab-ssh-pubkey`, 400 `unsupported key type`, 429
32 already-active-session.
33
34 Fully offline (~0.15s); allocates no Colab runtime.
35 """
36
37 import base64
38 import hashlib
39 import socket
40 import threading
41 from dataclasses import dataclass, field
42 from typing import Dict, List, Optional, Tuple
43
44 from colab_cli.commands import ssh
45 from colab_cli.state import SessionState
46 import pytest
47 import typer
48
49 # A structurally-valid ed25519 public key. ed25519 is the only key type the
50 # server accepts (RSA .pub tokens are `ssh-rsa`, rejected with 400 -- see
51 # websocket_to_ssh.ts ALLOWED_KEY_TYPES). The distinctive marker lets us prove
52 # the value crosses the wire verbatim.
53 PUBKEY = (
54 "ssh-ed25519 "
55 "AAAAC3NzaC1lZDI1NTE5AAAAIWIRECONTRACTMARKER00000000000000000000 "
56 "wire-contract@colab-cli-test"
57 )
58 TOKEN = "WIRE_CONTRACT_TOKEN_abc123"
59
60 _WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
61
62
63 # --- loopback capture server -------------------------------------------------
64
65
66 @dataclass
67 class CapturedRequest:
68 """The raw HTTP upgrade request the client sent, parsed."""
69
70 raw: bytes
71 method: str
72 target: str # path + query, e.g. "/colab/ssh?colab-runtime-proxy-token=..."
73 headers: Dict[str, str] = field(default_factory=dict)
74 headers_lower: Dict[str, Tuple[str, str]] = field(default_factory=dict)
75
76 @property
77 def path(self) -> str:
78 return self.target.split("?", 1)[0]
79
80 @property
81 def query(self) -> str:
82 return self.target.split("?", 1)[1] if "?" in self.target else ""
83
84
85 def _parse_request(raw: bytes) -> CapturedRequest:
86 head = raw.split(b"\r\n\r\n", 1)[0].decode("latin-1")
87 lines = head.split("\r\n")
88 method, target, _proto = lines[0].split(" ", 2)
89 req = CapturedRequest(raw=raw, method=method, target=target)
90 for line in lines[1:]:
91 if not line:
92 continue
93 name, _, value = line.partition(":")
94 name = name.strip()
95 value = value.strip()
96 req.headers[name] = value
97 req.headers_lower[name.lower()] = (name, value)
98 return req
99
100
101 class LoopbackWSServer:
102 """A single-shot loopback server that captures the client's upgrade request.
103
104 Two modes:
105 * mode="handshake": complete a minimal RFC6455 101 handshake so the real
106 `_connect_websocket` returns a live WebSocket (success path).
107 * mode="status": return a controlled HTTP status + body (with
108 Content-Length so websocket-client populates resp_body), exercising the
109 real WebSocketBadStatusException error-mapping path.
110 """
111
112 def __init__(
113 self,
114 mode: str = "handshake",
115 status: int = 400,
116 reason: str = "Bad Request",
117 body: bytes = b"",
118 ):
119 self.mode = mode
120 self.status = status
121 self.reason = reason
122 self.body = body
123 self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
124 self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
125 self._sock.bind(("127.0.0.1", 0))
126 self._sock.listen(1)
127 self.port = self._sock.getsockname()[1]
128 self.captured: Optional[CapturedRequest] = None
129 self._captured_evt = threading.Event()
130 self._thread = threading.Thread(target=self._serve, daemon=True)
131
132 def start(self) -> "LoopbackWSServer":
133 self._thread.start()
134 return self
135
136 def _serve(self) -> None:
137 self._sock.settimeout(10)
138 try:
139 conn, _ = self._sock.accept()
140 except OSError:
141 return
142 with conn:
143 conn.settimeout(10)
144 data = b""
145 try:
146 while b"\r\n\r\n" not in data:
147 chunk = conn.recv(4096)
148 if not chunk:
149 break
150 data += chunk
151 except OSError:
152 pass
153 if data:
154 self.captured = _parse_request(data)
155 self._captured_evt.set()
156 try:
157 conn.sendall(self._response(self.captured))
158 except OSError:
159 return
160 if self.mode == "handshake":
161 # Drain the client's close frame so its ws.close() returns
162 # promptly, then let the socket close.
163 try:
164 conn.recv(4096)
165 except OSError:
166 pass
167
168 def _response(self, req: Optional[CapturedRequest]) -> bytes:
169 if self.mode == "handshake":
170 key = (
171 req.headers_lower.get("sec-websocket-key", ("", ""))[1]
172 if req
173 else ""
174 )
175 accept = base64.b64encode(
176 hashlib.sha1((key + _WS_GUID).encode()).digest()
177 ).decode()
178 return (
179 "HTTP/1.1 101 Switching Protocols\r\n"
180 "Upgrade: websocket\r\n"
181 "Connection: Upgrade\r\n"
182 f"Sec-WebSocket-Accept: {accept}\r\n"
183 "\r\n"
184 ).encode()
185 # status mode: MUST send Content-Length -- websocket-client only reads
186 # the response body into WebSocketBadStatusException.resp_body when
187 # Content-Length is present. Omitting it would silently degrade the
188 # client to the generic 400 message and hide the `unsupported key type`
189 # branch.
190 headers = (
191 f"HTTP/1.1 {self.status} {self.reason}\r\n"
192 "Connection: close\r\n"
193 f"Content-Length: {len(self.body)}\r\n"
194 "\r\n"
195 ).encode()
196 return headers + self.body
197
198 def wait_for_request(self, timeout: float = 10.0) -> CapturedRequest:
199 if not self._captured_evt.wait(timeout):
200 raise AssertionError("loopback server never captured a request")
201 assert self.captured is not None
202 return self.captured
203
204 def close(self) -> None:
205 try:
206 self._sock.close()
207 except OSError:
208 pass
209 self._thread.join(timeout=5)
210
211
212 @pytest.fixture
213 def ws_server_factory():
214 servers: List[LoopbackWSServer] = []
215
216 def _make(**kw) -> LoopbackWSServer:
217 s = LoopbackWSServer(**kw).start()
218 servers.append(s)
219 return s
220
221 yield _make
222 for s in servers:
223 s.close()
224
225
226 def _session_for(port: int, token: str = TOKEN) -> SessionState:
227 """A real SessionState (not a MagicMock) pointing at the loopback server."""
228 return SessionState(
229 name="wire-test",
230 token=token,
231 url=f"http://127.0.0.1:{port}",
232 endpoint="wire-endpoint",
233 )
234
235
236 # --- the reusable contract assertion (this is what mutation must break) ------
237
238
239 def _assert_wire_contract(
240 req: CapturedRequest, token: str, pubkey: str
241 ) -> None:
242 """Assert the captured upgrade request honors the SSH wire contract.
243
244 A wrong `_SSH_PATH` breaks the path/query assertions; a wrong
245 `_PUBKEY_HEADER` breaks the header-name assertion. The mocked suite can
246 catch neither -- see test_mutation_* below, which run the SAME assertion
247 against a mutated client and prove it raises.
248 """
249 assert req.method == "GET", f"expected GET, got {req.method!r}"
250 assert req.path == "/colab/ssh", f"wrong route path: {req.path!r}"
251 assert f"colab-runtime-proxy-token={token}" in req.query, (
252 f"token missing/wrong in query: {req.query!r}"
253 )
254 assert "x-colab-ssh-pubkey" in req.headers_lower, (
255 f"pubkey header absent; headers sent: {sorted(req.headers)!r}"
256 )
257 sent_name, sent_value = req.headers_lower["x-colab-ssh-pubkey"]
258 assert sent_name == "X-Colab-Ssh-Pubkey", (
259 f"header name on wire: {sent_name!r}"
260 )
261 assert sent_value == pubkey, f"pubkey not verbatim: {sent_value!r}"
262
263
264 # --- the real-wire contract, success path ------------------------------------
265
266
267 def test_wire_contract_path_token_and_pubkey_header(ws_server_factory):
268 """The real connect path puts the contracted bytes on the wire.
269
270 No mock of _connect_websocket / websocket.connect: the header-emitting line
271 in ssh.py executes for real against a loopback server, and we assert on what
272 the server received.
273 """
274 server = ws_server_factory(mode="handshake")
275 session = _session_for(server.port)
276
277 url = ssh._build_ws_url(session)
278 ws = ssh._connect_websocket(url, PUBKEY) # real handshake, real header line
279 try:
280 ws.close()
281 except Exception:
282 pass
283
284 req = server.wait_for_request()
285 _assert_wire_contract(req, TOKEN, PUBKEY)
286
287
288 def test_wire_contract_distinct_token_reaches_wire(ws_server_factory):
289 """A per-session token is what actually appears in the query string."""
290 server = ws_server_factory(mode="handshake")
291 token = "DISTINCT_TOKEN_zzz999"
292 session = _session_for(server.port, token=token)
293
294 ws = ssh._connect_websocket(ssh._build_ws_url(session), PUBKEY)
295 try:
296 ws.close()
297 except Exception:
298 pass
299
300 req = server.wait_for_request()
301 assert f"colab-runtime-proxy-token={token}" in req.query
302
303
304 # --- prove the contract catches the mutations the mocked suite misses ---------
305
306
307 @pytest.mark.parametrize(
308 ("attr", "value", "reached_wire"),
309 [
310 (
311 "_SSH_PATH",
312 "/api/colab/ssh",
313 lambda req: req.path == "/api/colab/ssh",
314 ),
315 (
316 "_PUBKEY_HEADER",
317 "X-Wrong-Ssh-Pubkey",
318 lambda req: (
319 "x-wrong-ssh-pubkey" in req.headers_lower
320 and "x-colab-ssh-pubkey" not in req.headers_lower
321 ),
322 ),
323 ],
324 ids=["wrong-ssh-path", "wrong-pubkey-header"],
325 )
326 def test_mutation_reaches_wire_and_breaks_contract(
327 ws_server_factory, monkeypatch, attr, value, reached_wire
328 ):
329 """Mutating a wire constant (path / header name) really changes the bytes
330 on the wire and makes the contract assertion fail -- something the mocked
331 suite cannot detect."""
332 monkeypatch.setattr(ssh, attr, value)
333 server = ws_server_factory(mode="handshake")
334 session = _session_for(server.port)
335
336 ws = ssh._connect_websocket(ssh._build_ws_url(session), PUBKEY)
337 try:
338 ws.close()
339 except Exception:
340 pass
341
342 req = server.wait_for_request()
343 assert reached_wire(req) # the mutation really reached the wire
344 with pytest.raises(AssertionError):
345 _assert_wire_contract(req, TOKEN, PUBKEY)
346
347
348 # --- real HTTP status mapping (no mocked exception) --------------------------
349
350
351 @pytest.mark.parametrize(
352 ("status", "reason", "body", "must_contain"),
353 [
354 (400, "Bad Request", b"unsupported key type", "unsupported key type"),
355 (
356 429,
357 "Too Many Requests",
358 b'{"error":"already-active-session"}',
359 "Already-active SSH",
360 ),
361 ],
362 )
363 def test_real_status_mapping_via_loopback(
364 ws_server_factory, capsys, status, reason, body, must_contain
365 ):
366 """A real HTTP error from the server -> real WebSocketBadStatusException ->
367 `_explain_handshake_failure` -> actionable stderr + exit code 1.
368
369 Exercises the genuine error path (including websocket-client reading the
370 Content-Length body into resp_body), which the mocked suite only reaches by
371 hand-constructing the exception with a mocked resp_body.
372 """
373 server = ws_server_factory(
374 mode="status", status=status, reason=reason, body=body
375 )
376 session = _session_for(server.port)
377
378 with pytest.raises(typer.Exit) as exc_info:
379 ssh._connect_websocket(ssh._build_ws_url(session), PUBKEY)
380 assert exc_info.value.exit_code == 1
381
382 err = capsys.readouterr().err
383 assert must_contain in err
384 if status == 400:
385 # the remediation hint only fires when resp_body was really read
386 assert "ssh-keygen -t ed25519" in err
387
388 # the server must still have seen a well-formed contracted request even on
389 # the rejection path.
390 req = server.wait_for_request()
391 _assert_wire_contract(req, TOKEN, PUBKEY)