Files
saphid--frame-control/tests/e2e/test_pairing.py
T
saphidandClaude Opus 5.5 93aebb5019 Fix the review's findings in the test harness
Headset smoke test: drop the paired key from authorized_keys with a
same-mode copy swapped in, so a failed write can't truncate it; check the
throwaway key with no ssh_config or agent; clean up idempotently (tracked
and leftover titles, their json files, Steam's shortcuts via steamos-delete,
and ~/devkit-utils if it wasn't there before), with a failed cleanup a
failed step; count a launch only with fresh evidence (the process, Steam's
log, or the known missing-runtime line), matching with [d]evkit-game so
pgrep doesn't find its own shell. The test programs sleep 10 s.

Fake Frame: log a launch before its reaper can look for it. e2e: kill a
pairing client's process group when a test ends; accept an aarch64 program
running under QEMU on x86 hosts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 17:40:09 +10:00

124 lines
5.3 KiB
Python

"""Setting up the connection: ui/frame_connect.py against Valve's real
steamos-devkit-service on the fake Frame, and its password fallback."""
import json
import os
import signal
import stat
import subprocess
import sys
import unittest
import urllib.request
import harness
from harness import FAKE_HOST, ROOT, ctl, state, wait_for
harness.require()
class Pairing(harness.FrameTestCase):
def setUp(self):
super().setUp()
ctl('keys', 'none') # a computer the headset doesn't know yet
def connect(self, askpass=None):
"""Start frame_connect.py FAKE_HOST with no terminal, as the app's setup window would."""
env = {k: v for k, v in os.environ.items() if not k.startswith('SSH_ASKPASS')}
if askpass:
script = self.path('askpass')
with open(script, 'w') as f:
f.write(f'#!/bin/sh\necho {askpass}\n')
os.chmod(script, stat.S_IRWXU)
env.update(SSH_ASKPASS=script, SSH_ASKPASS_REQUIRE='force')
proc = subprocess.Popen([sys.executable, str(ROOT / 'ui' / 'frame_connect.py'), FAKE_HOST],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, env=env, start_new_session=True)
self.addCleanup(self.stop, proc) # a failed test mustn't leave it pairing into the next one
return proc
@staticmethod
def stop(proc):
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGKILL) # frame_connect.py and its ssh children
except OSError:
pass
proc.communicate()
def finish(self, proc, timeout=120):
out, _ = proc.communicate(timeout=timeout)
return proc.returncode, out
def authorized_keys(self):
return ctl('authorized-keys')['text']
def test_service_properties_and_announcement(self):
# docs/ssh.md: properties.json answers "login": "steamos" on the Frame.
with urllib.request.urlopen(f'http://{FAKE_HOST}:32000/properties.json', timeout=10) as r:
props = json.load(r)
self.assertEqual(props['login'], 'steamos')
self.assertEqual(props['devkit1'], ['devkit-1'])
# At start the service announces _steamos-devkit._tcp under the Frame's hostname.
ctl('devkit-service', 'off')
ctl('devkit-service', 'on')
reg = wait_for(lambda: [c for c in harness.calls('resolve1') if c['method'] == 'RegisterService'],
15, 'the mDNS registration')
self.assertEqual(reg[0]['args'][:3], ['frame', 'frame', '_steamos-devkit._tcp'])
self.assertEqual(reg[0]['args'][3], 32000)
def test_pairing_mode_refusal_then_approval(self):
proc = self.connect()
# The first /register is refused: "Pair new host" isn't open.
wait_for(lambda: any(r['answer'] == 'not in pairing mode' for r in state()['pairing_requests']),
30, 'a refused pairing request')
ctl('pairing', 'on') # the user opens Settings > Developer > Pair new host
rc, out = self.finish(proc)
self.assertEqual(rc, 0, out)
self.assertIn('paired; key login OK', out)
answers = [r['answer'] for r in state()['pairing_requests']]
self.assertEqual(answers[-1], 'approve')
self.assertIn('not in pairing mode', answers)
self.assertIn('frame-control@', state()['pairing_requests'][-1]['request'])
# The hook turned sshd on and installed the RSA key for steamos.
self.assertTrue(harness.calls('steamos-enable-sshd'))
keys = self.authorized_keys()
self.assertRegex(keys, r'(?m)^ssh-rsa \S+ frame-control@\S+$')
self.assertNotIn('900b919520e4cf601998a71eec318fec', keys) # the magic phrase isn't stored
def test_denied_request_falls_back_to_the_password(self):
ctl('pairing', 'on')
ctl('answer', 'deny')
rc, out = self.finish(self.connect()) # no password to give: the fallback can't finish
self.assertEqual(rc, 1, out)
self.assertIn('devkit pairing failed: the pairing request was denied', out)
self.assertIn('falling back to the password', out)
self.assertNotIn('ssh-rsa', self.authorized_keys())
def test_service_down_uses_the_password(self):
ctl('devkit-service', 'off')
rc, out = self.finish(self.connect(askpass='frame'))
self.assertEqual(rc, 0, out)
self.assertIn('devkit service not reachable on port 32000', out)
self.assertIn('key login OK', out)
with open(os.path.expanduser('~/.ssh/id_ed25519_frame.pub')) as f:
ours = f.read().split()[1]
self.assertIn(ours, self.authorized_keys())
def test_prompt_left_unanswered_times_out(self):
# approve-ssh-key waits 30 s for Steam, then says so.
ctl('pairing', 'on')
ctl('answer', 'timeout')
rc, out = self.finish(self.connect(), timeout=150)
self.assertEqual(rc, 1, out)
self.assertIn('timeout - Steam did not respond to the pairing request', out)
def test_steam_not_running(self):
ctl('pairing', 'on')
ctl('steam', 'off')
rc, out = self.finish(self.connect())
self.assertEqual(rc, 1, out)
self.assertIn('devkit pairing failed: Steam is not running', out)
if __name__ == '__main__':
unittest.main()