mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
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>
124 lines
5.3 KiB
Python
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()
|