mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 18:01:56 +02:00
152 lines
7.4 KiB
Python
152 lines
7.4 KiB
Python
"""Storage inventory uses only local temporary metadata; no headset connection."""
|
|
import sandbox # noqa: F401
|
|
import http.client
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / 'ui'))
|
|
import frame_storage as storage
|
|
import server
|
|
|
|
|
|
class Storage(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.home = Path(self.tmp.name)
|
|
self.steam = self.home / '.local/share/Steam'
|
|
(self.steam / 'steamapps').mkdir(parents=True)
|
|
|
|
def write(self, relative, text):
|
|
path = self.steam / 'steamapps' / relative
|
|
path.write_text(text)
|
|
return path
|
|
|
|
def app(self, appid, name, size=None):
|
|
return self.write(f'appmanifest_{appid}.acf', '"AppState" { "appid" "%s" "name" "%s" %s }' %
|
|
(appid, name, '' if size is None else '"SizeOnDisk" "%s"' % size))
|
|
|
|
def inventory(self):
|
|
return storage.inventory(self.home, self.steam)
|
|
|
|
def test_multilibrary_dedup_sizes_and_read_only(self):
|
|
extra = self.home / 'other library'
|
|
(extra / 'steamapps').mkdir(parents=True)
|
|
self.write('libraryfolders.vdf', '"libraryfolders" { "0" { "path" "%s" } "1" { "path" "%s" } "2" "%s" }' %
|
|
(self.steam, extra, extra))
|
|
self.app(1, 'Game', '400')
|
|
self.app(2, 'Proton 10', '100')
|
|
self.app(3, 'Unknown')
|
|
self.app(4, 'Zero', '0')
|
|
self.app(5, 'Bad', '-10')
|
|
(extra / 'steamapps/appmanifest_9.acf').write_text('"AppState" { "appid" "9" "name" "Other" "SizeOnDisk" "200" }')
|
|
before = {p: p.read_bytes() for p in self.home.rglob('*') if p.is_file()}
|
|
result = self.inventory()
|
|
self.assertEqual(result['discovery'], 'ok')
|
|
self.assertEqual(len(result['libraries']), 2)
|
|
lib = result['libraries'][0]
|
|
self.assertEqual((lib['gameBytes'], lib['toolBytes'], lib['recordedBytes'], lib['unknownSizes']), (400, 100, 500, 2))
|
|
self.assertEqual([a['bytes'] for a in lib['apps']], [400, 100, 0, None, None])
|
|
self.assertEqual(result['libraries'][1]['recordedBytes'], 200)
|
|
self.assertEqual(before, {p: p.read_bytes() for p in self.home.rglob('*') if p.is_file()})
|
|
|
|
def test_partial_and_corrupt_metadata_never_become_empty_success(self):
|
|
self.write('libraryfolders.vdf', '"libraryfolders" {')
|
|
self.app(1, 'Good', '12')
|
|
self.write('appmanifest_2.acf', '"AppState" { "appid" "2"')
|
|
self.write('appmanifest_3.acf', '"AppState" { "appid" "9" }')
|
|
r = self.inventory()
|
|
self.assertEqual(r['discovery'], 'invalid-metadata')
|
|
lib = r['libraries'][0]
|
|
self.assertEqual((lib['state'], lib['recordedBytes'], len(lib['issues'])), ('partial', 12, 2))
|
|
|
|
def test_permission_denied_discovery_listing_manifest_and_capacity(self):
|
|
original = storage.read_vdf
|
|
self.app(1, 'Game', '10')
|
|
def read(path):
|
|
if str(path).endswith('appmanifest_1.acf') or str(path).endswith('libraryfolders.vdf'):
|
|
raise PermissionError('denied')
|
|
return original(path)
|
|
with patch.object(storage, 'read_vdf', side_effect=read), patch.object(storage.os, 'statvfs', side_effect=PermissionError(), create=True):
|
|
r = self.inventory()
|
|
self.assertEqual(r['discovery'], 'permission-denied')
|
|
self.assertEqual(r['home']['state'], 'permission-denied')
|
|
self.assertIsNone(r['home']['free'])
|
|
self.assertEqual(r['libraries'][0]['issues'][0]['state'], 'permission-denied')
|
|
with patch.object(storage.os, 'scandir', side_effect=PermissionError()):
|
|
self.assertEqual(self.inventory()['libraries'][0]['state'], 'permission-denied')
|
|
|
|
def test_disconnected_library_and_missing_discovery(self):
|
|
r = self.inventory()
|
|
self.assertEqual(r['discovery'], 'missing')
|
|
missing = self.home / 'disconnected'
|
|
self.write('libraryfolders.vdf', '"libraryfolders" { "1" { "path" "%s" } }' % missing)
|
|
lib = self.inventory()['libraries'][1]
|
|
self.assertEqual(lib['state'], 'missing')
|
|
self.assertIsNone(lib['capacity']['total'])
|
|
|
|
def test_reserved_capacity_and_shared_filesystem(self):
|
|
stat = SimpleNamespace(f_blocks=100, f_frsize=1024, f_bavail=10, f_bfree=20)
|
|
with patch.object(storage.os, 'statvfs', return_value=stat, create=True):
|
|
r = self.inventory()
|
|
self.assertEqual((r['home']['total'], r['home']['free'], r['home']['used']), (102400, 10240, 81920))
|
|
self.assertEqual(r['home']['filesystem'], r['libraries'][0]['capacity']['filesystem'])
|
|
|
|
def test_keyvalues_comments_escaping_and_limits(self):
|
|
path = self.write('parse.vdf', '// comment\n"root" { "name" "A \\"quote\\"" "nested" { "size" "42" } }')
|
|
self.assertEqual(storage.read_vdf(path)['root']['name'], 'A "quote"')
|
|
path.write_text('"root" { "name" "}" }')
|
|
self.assertEqual(storage.read_vdf(path)['root']['name'], '}')
|
|
for bad in ['"a" {', '"a" "1" "a" "2"', '"a" }', 'garbage', '"a" {"b" "c"}}']:
|
|
path.write_text(bad)
|
|
with self.assertRaises(ValueError):
|
|
storage.read_vdf(path)
|
|
path.write_text('x' * (storage.MAX_TEXT + 1))
|
|
with self.assertRaises(ValueError):
|
|
storage.read_vdf(path)
|
|
|
|
def test_http_probe_is_guarded_and_read_only(self):
|
|
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
with patch.object(server, 'ssh', return_value=json.dumps(self.inventory())) as ssh:
|
|
conn = http.client.HTTPConnection(*httpd.server_address)
|
|
conn.request('GET', '/api/storage')
|
|
response = conn.getresponse()
|
|
self.assertEqual(response.status, 403)
|
|
response.read()
|
|
ssh.assert_not_called()
|
|
conn.request('GET', '/api/storage', headers={'X-Frame-UI': server.UI_KEY})
|
|
response = conn.getresponse()
|
|
self.assertEqual(response.status, 200)
|
|
self.assertIn('libraries', json.loads(response.read()))
|
|
self.assertEqual(ssh.call_args.args, ('python3 -',))
|
|
self.assertEqual(ssh.call_args.kwargs['stdin'], (ROOT / 'ui/frame_storage.py').read_text())
|
|
with patch.object(server, 'LINK', SimpleNamespace(active_device=lambda: {'id': 'new-frame'})):
|
|
ssh.reset_mock()
|
|
conn.request('GET', '/api/storage', headers={'X-Frame-UI': server.UI_KEY, 'X-Frame-Device': 'old-frame'})
|
|
response = conn.getresponse()
|
|
self.assertEqual(response.status, 409)
|
|
response.read()
|
|
ssh.assert_not_called()
|
|
conn.close()
|
|
finally:
|
|
httpd.shutdown()
|
|
httpd.server_close()
|
|
thread.join()
|
|
|
|
@unittest.skipUnless(shutil.which('node'), 'Node required')
|
|
def test_ui_behavior(self):
|
|
r = subprocess.run(['node', '--test', str(ROOT / 'tests/storage.cjs')], capture_output=True, text=True)
|
|
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|