同步移动端工程、设备控制改进与发布资料
This commit is contained in:
@@ -86,7 +86,7 @@ def test_status_config_and_display_api(tmp_path):
|
||||
"effective": False,
|
||||
"current_governors": {},
|
||||
"restore_governors": {},
|
||||
"last_error": None,
|
||||
"last_error": "当前内核未提供 CPU 调频策略;请检查候选内核是否回退",
|
||||
}
|
||||
assert status.json()["state"]["display_test"] == {
|
||||
"active": False,
|
||||
|
||||
@@ -49,6 +49,13 @@ def test_check_profile_accepts_walnutpi_contract(tmp_path):
|
||||
]
|
||||
|
||||
|
||||
def test_bluetooth_is_not_a_display_startup_blocker(tmp_path):
|
||||
def runner(args):
|
||||
assert not any('bluetooth' in part for part in args)
|
||||
return healthy_runner(args)
|
||||
assert all(check.ok for check in check_profile(make_paths(tmp_path), runner=runner))
|
||||
|
||||
|
||||
def test_service_preflight_does_not_require_default_route(tmp_path):
|
||||
def runner(args):
|
||||
assert tuple(args) != ("ip", "route", "show", "default")
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from app.system import kernel_recovery as kr
|
||||
from app.ota.manager import OtaManager
|
||||
from app.ota.state import write_json, write_last_result
|
||||
from app.ota.versioning import SoftwareVersion
|
||||
from scripts.ota_worker import UpdateFailed, verify_kernel_capability_retained
|
||||
from scripts.axp313a_kernel_health import (
|
||||
HealthError,
|
||||
check_cpufreq,
|
||||
@@ -11,6 +18,159 @@ from scripts.axp313a_kernel_health import (
|
||||
from scripts.render_dual_kernel_boot import BootScriptError, render
|
||||
|
||||
|
||||
def make_recovery(tmp_path: Path, monkeypatch) -> kr.KernelRecovery:
|
||||
host = tmp_path / "host"
|
||||
data = tmp_path / "data"
|
||||
boot = host / "boot"
|
||||
boot.mkdir(parents=True)
|
||||
files = {
|
||||
"Image-matrix-axp313a1": b"candidate-image",
|
||||
"sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb": b"candidate-sd-dtb",
|
||||
"sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb": b"candidate-emmc-dtb",
|
||||
}
|
||||
monkeypatch.setattr(kr, "BOOT_HASHES", {name: hashlib.sha256(value).hexdigest() for name, value in files.items()})
|
||||
for name, value in files.items():
|
||||
(boot / name).write_bytes(value)
|
||||
for name in ("Image", "sun50i-h616-walnutpi-1b.dtb", "sun50i-h616-walnutpi-1b-emmc.dtb",
|
||||
"boot.cmd.matrix-original", "boot.scr.matrix-original", "matrix-original.SHA256SUMS"):
|
||||
(boot / name).write_bytes(b"original")
|
||||
(boot / "boot.cmd").write_text(
|
||||
"matrix-kernel-good matrix-kernel-good matrix_kernel_candidate=1",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(boot / "boot.scr").write_bytes(b"matrix_kernel_candidate=1 Image-matrix-axp313a1")
|
||||
health = host / "etc/systemd/system/matrix-axp313a-health.service"
|
||||
health.parent.mkdir(parents=True)
|
||||
health.write_text("ConditionKernelCommandLine=matrix_kernel_candidate=1", encoding="utf-8")
|
||||
script = host / "opt/matrix-screen-controller-system/axp313a_kernel_health.py"
|
||||
script.parent.mkdir(parents=True)
|
||||
script.write_text("health", encoding="utf-8")
|
||||
enabled = host / "etc/systemd/system/multi-user.target.wants/matrix-axp313a-health.service"
|
||||
enabled.parent.mkdir(parents=True)
|
||||
enabled.write_text("enabled", encoding="utf-8")
|
||||
modules = host / "lib/modules" / kr.CANDIDATE_RELEASE
|
||||
(modules / "kernel").mkdir(parents=True)
|
||||
(modules / "modules.dep").write_text("index", encoding="ascii")
|
||||
module = modules / "kernel/example.ko"
|
||||
module.write_bytes(b"module")
|
||||
digest = hashlib.sha256(b"kernel/example.ko\0" + hashlib.sha256(b"module").digest()).hexdigest()
|
||||
monkeypatch.setattr(kr, "MODULE_COUNT", 1)
|
||||
monkeypatch.setattr(kr, "MODULE_DIGEST", digest)
|
||||
release = host / "proc/sys/kernel/osrelease"
|
||||
release.parent.mkdir(parents=True)
|
||||
release.write_text("6.1.31", encoding="ascii")
|
||||
boot_id = host / "proc/sys/kernel/random/boot_id"
|
||||
boot_id.parent.mkdir(parents=True)
|
||||
boot_id.write_text("old-boot", encoding="ascii")
|
||||
return kr.KernelRecovery(data, host_root=host)
|
||||
|
||||
|
||||
def test_candidate_validation_and_single_recovery_attempt(tmp_path, monkeypatch):
|
||||
recovery = make_recovery(tmp_path, monkeypatch)
|
||||
assert recovery.verify_candidate() is None
|
||||
assert recovery.prepare_attempt("1.2.0:time") is True
|
||||
assert recovery.prepare_attempt("1.2.0:time") is False
|
||||
monkeypatch.setattr(kr.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 1))
|
||||
recovery.arm_and_reboot(reboot=False)
|
||||
assert recovery.status()["state"] == "pending"
|
||||
with pytest.raises(RuntimeError, match="no unused"):
|
||||
recovery.arm_and_reboot(reboot=False)
|
||||
recovery.host("/proc/sys/kernel/random/boot_id").write_text("new-boot", encoding="ascii")
|
||||
recovery.host("/proc/sys/kernel/osrelease").write_text(kr.CANDIDATE_RELEASE, encoding="ascii")
|
||||
policy = recovery.host("/sys/devices/system/cpu/cpufreq/policy0")
|
||||
policy.mkdir(parents=True)
|
||||
(policy / "scaling_governor").write_text("schedutil", encoding="ascii")
|
||||
(policy / "scaling_available_governors").write_text("schedutil performance", encoding="ascii")
|
||||
recovery.finalize_after_boot(wait_seconds=1)
|
||||
assert recovery.status()["state"] == "succeeded"
|
||||
|
||||
|
||||
def test_recovery_failure_and_damaged_candidate_do_not_retry(tmp_path, monkeypatch):
|
||||
recovery = make_recovery(tmp_path, monkeypatch)
|
||||
recovery.host("/boot/Image-matrix-axp313a1").write_bytes(b"damaged")
|
||||
assert recovery.prepare_attempt("1.2.0:time") is False
|
||||
assert recovery.status()["state"] == "requires_package"
|
||||
assert recovery.prepare_attempt("1.2.1:later") is False
|
||||
|
||||
other = make_recovery(tmp_path / "other", monkeypatch)
|
||||
assert other.prepare_attempt("1.2.0:time") is True
|
||||
monkeypatch.setattr(kr.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 1))
|
||||
other.arm_and_reboot(reboot=False)
|
||||
other.host("/proc/sys/kernel/random/boot_id").write_text("new-boot", encoding="ascii")
|
||||
other.finalize_after_boot(wait_seconds=1)
|
||||
assert other.status()["state"] == "failed"
|
||||
assert other.prepare_attempt("1.2.1:later") is False
|
||||
|
||||
|
||||
def test_scheduled_recovery_that_never_runs_becomes_persistent_failure(tmp_path, monkeypatch):
|
||||
recovery = make_recovery(tmp_path, monkeypatch)
|
||||
assert recovery.prepare_attempt("1.2.0:time") is True
|
||||
monkeypatch.setattr(kr.time, "time", lambda: recovery.read_record()["recorded_at"] + 181)
|
||||
assert recovery.status()["state"] == "failed"
|
||||
assert recovery.read_record()["state"] == "failed"
|
||||
assert recovery.prepare_attempt("1.2.1:later") is False
|
||||
|
||||
|
||||
def test_delayed_recovery_task_does_not_reboot(tmp_path, monkeypatch):
|
||||
recovery = make_recovery(tmp_path, monkeypatch)
|
||||
assert recovery.prepare_attempt("1.2.0:time") is True
|
||||
monkeypatch.setattr(kr.time, "time", lambda: recovery.read_record()["recorded_at"] + 181)
|
||||
monkeypatch.setattr(kr.subprocess, "run", lambda *args, **kwargs: pytest.fail("late task ran a command"))
|
||||
recovery.arm_and_reboot()
|
||||
assert recovery.read_record()["state"] == "failed"
|
||||
|
||||
|
||||
def test_ota_rejects_new_cpufreq_loss_and_keeps_preexisting_degradation():
|
||||
before = {"release": kr.CANDIDATE_RELEASE, "available": True, "marker": True,
|
||||
"policies": {"policy0": {"current": "schedutil", "performance": True}}}
|
||||
after = {**before, "policies": {}, "available": False}
|
||||
off = {"requested": False, "available": True, "effective": False,
|
||||
"current_governors": {"policy0": "schedutil"}}
|
||||
with pytest.raises(UpdateFailed, match="lost the validated"):
|
||||
verify_kernel_capability_retained(before, after, off, off)
|
||||
verify_kernel_capability_retained({**before, "available": False}, after, off, off)
|
||||
with pytest.raises(UpdateFailed, match="governors"):
|
||||
verify_kernel_capability_retained(before, before, off, {**off, "current_governors": {"policy0": "performance"}})
|
||||
|
||||
|
||||
def test_legacy_worker_completion_schedules_lightweight_recovery(tmp_path, monkeypatch):
|
||||
class Display:
|
||||
def start_ota_indicator(self, _path):
|
||||
pass
|
||||
|
||||
def stop_ota_indicator(self):
|
||||
pass
|
||||
|
||||
class Recovery:
|
||||
def __init__(self):
|
||||
self.attempts = []
|
||||
|
||||
def status(self):
|
||||
return {"state": "ready", "reason": ""}
|
||||
|
||||
def prepare_attempt(self, result_id):
|
||||
self.attempts.append(result_id)
|
||||
return len(self.attempts) == 1
|
||||
|
||||
recovery = Recovery()
|
||||
manager = OtaManager(
|
||||
code_root=tmp_path / "code", data_root=tmp_path / "data", runtime_root=tmp_path / "run",
|
||||
software_version=SoftwareVersion.parse("1.2.0"), display=Display(),
|
||||
)
|
||||
monkeypatch.setattr(manager, "_production_recovery", lambda: recovery)
|
||||
calls = []
|
||||
monkeypatch.setattr("app.ota.manager.subprocess.run", lambda command, **kwargs: calls.append(command))
|
||||
write_json(manager.status_path, {"schema_version": 1, "active": True, "job": {"id": "old-worker"}})
|
||||
assert manager.resume_or_start_monitor(manager.schedule_kernel_recovery_after_ota)
|
||||
write_last_result(manager.data_root, {"status": "success", "target_version": "1.2.0",
|
||||
"installed_at": "2026-09-26T08:00:00Z"})
|
||||
write_json(manager.status_path, {"schema_version": 1, "active": False, "job": {"id": "old-worker"}})
|
||||
manager._monitor_thread.join(timeout=3)
|
||||
assert recovery.attempts == ["1.2.0:2026-09-26T08:00:00Z"]
|
||||
assert len(calls) == 1
|
||||
assert "systemd-run" in calls[0]
|
||||
|
||||
|
||||
BOOT_SOURCE = '''# DO NOT EDIT THIS FILE
|
||||
setenv docker_optimizations "on"
|
||||
setenv bootargs "root=/dev/mmcblk1p2"
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import create_app
|
||||
from app.mobile.session import RpcError
|
||||
|
||||
|
||||
def test_identity_persistence_and_rename(tmp_path):
|
||||
app = create_app(project_root=tmp_path, driver_kind='mock')
|
||||
mobile = app.state.mobile_control
|
||||
before = mobile.identity()
|
||||
renamed = mobile.dispatch('device.rename', {'name': '客厅小屏幕'})
|
||||
assert renamed['device_id'] == before['device_id']
|
||||
assert renamed['device_name'] == '客厅小屏幕'
|
||||
other = create_app(project_root=tmp_path, driver_kind='mock')
|
||||
assert other.state.mobile_control.identity() == renamed
|
||||
|
||||
|
||||
def test_web_change_invalidates_mobile_revision(tmp_path):
|
||||
app = create_app(project_root=tmp_path, driver_kind='mock')
|
||||
mobile = app.state.mobile_control
|
||||
settings = mobile.dispatch('settings.get', {})
|
||||
client = TestClient(app)
|
||||
assert client.put('/api/config', json={'brightness': 37}).status_code == 200
|
||||
with pytest.raises(RpcError, match='CONFLICT'):
|
||||
mobile.dispatch('settings.patch', {'expected_revision': settings['revision'], 'changes': {'brightness': 20}})
|
||||
revision = mobile.settings()['revision']
|
||||
mobile.dispatch('settings.patch', {'expected_revision': revision, 'changes': {'brightness': 25}})
|
||||
assert client.get('/api/config').json()['brightness'] == 25
|
||||
|
||||
|
||||
def test_library_and_frame_readback(tmp_path):
|
||||
app = create_app(project_root=tmp_path, driver_kind='mock')
|
||||
mobile = app.state.mobile_control
|
||||
first = mobile.dispatch('library.list', {'limit': 1})
|
||||
assert len(first['items']) == 1 and first['next_cursor']
|
||||
second = mobile.dispatch('library.list', {'cursor': first['next_cursor'], 'limit': 1})
|
||||
assert second['items'][0]['id'] != first['items'][0]['id']
|
||||
item = first['items'][0]
|
||||
assert mobile.dispatch('library.thumbnail', item)['mime'] == 'image/png'
|
||||
mobile.dispatch('content.play', item)
|
||||
frame = mobile.dispatch('frame.get', {})
|
||||
assert mobile.dispatch('frame.get', {'known_revision': frame['frame_revision']})['unchanged']
|
||||
with pytest.raises(RpcError, match='CONFLICT'):
|
||||
mobile.dispatch('content.play', {**item, 'revision': 'old'})
|
||||
|
||||
|
||||
def test_forbidden_operation_and_settings_fields(tmp_path):
|
||||
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
|
||||
with pytest.raises(RpcError, match='UNSUPPORTED_CAPABILITY'):
|
||||
mobile.dispatch('shell.run', {'command': 'anything'})
|
||||
with pytest.raises(RpcError, match='BAD_REQUEST'):
|
||||
mobile.dispatch('settings.patch', {'expected_revision': mobile.settings()['revision'], 'changes': {'low_voltage_protection_enabled': False}})
|
||||
|
||||
|
||||
def test_wifi_open_network_and_password_not_returned(tmp_path):
|
||||
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
|
||||
revision = mobile.dispatch('wifi.get', {})['revision']
|
||||
result = mobile.dispatch('wifi.set', dict(expected_revision=revision, ssid='Test Open', security='open',
|
||||
password_action='none', ipv4_mode='dhcp', dns_servers=[], activation='next_boot'))
|
||||
assert mobile.dispatch('task.get', {'task_id': result['task_id']})['state'] == 'succeeded'
|
||||
wifi = mobile.dispatch('wifi.get', {})
|
||||
assert 'password' not in wifi['saved']
|
||||
assert not wifi['saved']['password_configured']
|
||||
assert wifi['saved']['security'] == 'open'
|
||||
assert wifi['revision'] != revision
|
||||
mobile.dispatch('wifi.set', dict(expected_revision=wifi['revision'], ssid='Test Private', security='wpa-psk',
|
||||
password_action='replace', password='public-test-fixture', ipv4_mode='dhcp', activation='next_boot'))
|
||||
wifi = mobile.dispatch('wifi.get', {})
|
||||
assert 'password' not in wifi['saved'] and wifi['saved']['password_configured']
|
||||
|
||||
|
||||
def test_scan_escaped_ssid_deduplicates_and_marks_unsupported():
|
||||
from app.network.manager import NmcliNetworkManager
|
||||
class Stub(NmcliNetworkManager):
|
||||
def _run(self, args, *, timeout=10):
|
||||
assert timeout == 15 and '--rescan' in args
|
||||
return '*:Test\\:WiFi:WPA2:42\n:Test\\:WiFi:WPA2:81\n:Open:--:37\n:EAP:WPA2 802.1X:80'
|
||||
rows = Stub().scan()
|
||||
assert rows[0] == dict(ssid='Test:WiFi', security='wpa-psk', signal_percent=81, connected=True)
|
||||
assert rows[1]['security'] == 'unsupported'
|
||||
assert rows[2]['security'] == 'open'
|
||||
|
||||
|
||||
def test_mobile_default_and_animation_controls_share_web_state(tmp_path):
|
||||
app = create_app(project_root=tmp_path, driver_kind='mock')
|
||||
with TestClient(app) as client:
|
||||
mobile = app.state.mobile_control
|
||||
items = mobile.dispatch('library.list', {})['items']
|
||||
animation = next(item for item in items if item['type'] == 'animation' and item['playable'])
|
||||
mobile.dispatch('content.default.set', animation)
|
||||
assert mobile.dispatch('content.default.get', {})['id'] == animation['id']
|
||||
assert client.get('/api/display/default-content').json()['id'] == animation['id']
|
||||
playback = client.get('/api/status').json()['state']['animation_playback']
|
||||
controlled = mobile.dispatch('playback.patch', dict(
|
||||
session_id=playback['session_id'], paused=True, position_ms=0, speed=1.5))
|
||||
assert controlled['animation_playback']['paused']
|
||||
current = client.get('/api/status').json()['state']['animation_playback']
|
||||
assert current['position_ms'] == 0 and current['speed'] == 1.5
|
||||
mobile.dispatch('content.play', animation)
|
||||
with pytest.raises(RpcError, match='CONFLICT'):
|
||||
mobile.dispatch('playback.patch', dict(session_id=playback['session_id'], paused=False))
|
||||
|
||||
|
||||
def test_mobile_library_uses_persistent_mixed_order_and_invalidates_cursor(tmp_path):
|
||||
from app.demo_library import demo_template
|
||||
app = create_app(project_root=tmp_path, driver_kind='mock')
|
||||
mobile = app.state.mobile_control
|
||||
templates, animations = mobile.templates, mobile.animations
|
||||
import base64
|
||||
t = templates.create('user-template', {'version': 1, 'width': 64, 'height': 64, 'pixelRgb': base64.b64encode(bytes(64*64*3)).decode('ascii'), 'elements': []})
|
||||
# Empty animations are valid library items, even though not playable.
|
||||
a = animations.create('user-animation')
|
||||
defaults = [{'type': 'template', 'id': t['id']}, {'type': 'animation', 'id': a['id']}]
|
||||
store = app.state.library_order_store
|
||||
current = store.get(defaults)
|
||||
first = mobile.dispatch('library.list', {'limit': 1})
|
||||
order = list(reversed(defaults))
|
||||
store.update(order, default_items=defaults, expected_revision=current['revision'])
|
||||
items = mobile.dispatch('library.list', {})['items']
|
||||
assert [{'type': i['type'], 'id': i['id']} for i in items if not i['is_demo']] == order
|
||||
with pytest.raises(RpcError, match='CONFLICT'):
|
||||
mobile.dispatch('library.list', {'cursor': first['next_cursor']})
|
||||
|
||||
|
||||
@pytest.mark.parametrize('reason,expected', [(53, 'NETWORK_UNAVAILABLE'), (5, 'IP_CONFIG_FAILED'), (11, 'UNKNOWN'), (7, 'UNKNOWN')])
|
||||
def test_wifi_failure_classification_does_not_guess_authentication(monkeypatch, reason, expected):
|
||||
import subprocess
|
||||
from app.network.manager import NmcliNetworkManager, NetworkManagerError
|
||||
class Backend(NmcliNetworkManager):
|
||||
def _run(self, args, *, timeout=10):
|
||||
if 'up' in args:
|
||||
raise NetworkManagerError('sanitized')
|
||||
return str(reason) + ' (reason)'
|
||||
monkeypatch.setattr(subprocess, 'run', lambda *a, **k: subprocess.CompletedProcess(a, 0, '', ''))
|
||||
with pytest.raises(NetworkManagerError) as error:
|
||||
Backend().activate('public-test-id')
|
||||
assert error.value.error_code == expected
|
||||
|
||||
|
||||
def test_wifi_auth_requires_explicit_wrong_key_event(monkeypatch):
|
||||
import subprocess
|
||||
from app.network.manager import NmcliNetworkManager, NetworkManagerError
|
||||
class Backend(NmcliNetworkManager):
|
||||
def _run(self, args, *, timeout=10):
|
||||
if 'up' in args: raise NetworkManagerError('sanitized')
|
||||
return '7 (no-secrets)'
|
||||
monkeypatch.setattr(subprocess, 'run', lambda *a, **k: subprocess.CompletedProcess(a, 0,
|
||||
'wlan0: CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="fixture" auth_failures=1 duration=10 reason=WRONG_KEY', ''))
|
||||
with pytest.raises(NetworkManagerError) as error:
|
||||
Backend().activate('public-test-id')
|
||||
assert error.value.error_code == 'AUTH_FAILED'
|
||||
assert str(error.value) == 'WiFi activation failed'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure', ['AUTH_FAILED', 'NETWORK_UNAVAILABLE', 'IP_CONFIG_FAILED', 'UNKNOWN'])
|
||||
def test_task_returns_only_sanitized_failure_code(tmp_path, failure):
|
||||
from app.network.manager import NetworkManagerError
|
||||
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
|
||||
def fail(*args, **kwargs):
|
||||
raise NetworkManagerError('private backend detail must not escape', error_code=failure)
|
||||
mobile.dispatch('wifi.set', dict(expected_revision=mobile.wifi()['revision'], ssid='Test Open', security='open', password_action='none', ipv4_mode='dhcp', dns_servers=[], activation='next_boot'))
|
||||
mobile.network.backend.activate = fail
|
||||
task = 'public-test-task'
|
||||
mobile._activate(task)
|
||||
result = mobile.dispatch('task.get', {'task_id': task})
|
||||
assert result == {'state': 'failed', 'stage': 'finished', 'error_code': failure}
|
||||
assert 'private' not in str(result)
|
||||
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.mobile.protocol import Handshake, ProtocolError, Reassembler, fragments, json_object
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mtu", [23, 64, 247, 517])
|
||||
def test_fragments_roundtrip_and_replay(mtu):
|
||||
data = ("奇妙小屏幕" * 200).encode()
|
||||
parts = list(fragments(3, 0, data, mtu))
|
||||
receiver = Reassembler()
|
||||
for part in parts[:-1]:
|
||||
assert receiver.accept(part) is None
|
||||
assert receiver.accept(parts[-1]) == (3, data)
|
||||
with pytest.raises(ProtocolError):
|
||||
receiver.accept(parts[0])
|
||||
|
||||
|
||||
def test_fragment_reordering_and_deadline():
|
||||
parts = list(fragments(1, 0, b"hello world", 23))
|
||||
with pytest.raises(ProtocolError):
|
||||
Reassembler().accept(parts[1])
|
||||
now = [0.0]
|
||||
receiver = Reassembler(lambda: now[0])
|
||||
receiver.accept(parts[0])
|
||||
now[0] = 15
|
||||
with pytest.raises(ProtocolError, match="TIMEOUT"):
|
||||
receiver.accept(parts[1])
|
||||
|
||||
|
||||
def pair():
|
||||
client, server = Handshake(), Handshake()
|
||||
return client.finish(server.hello, server=False), server.finish(client.hello, server=True)
|
||||
|
||||
|
||||
def test_bidirectional_encryption_and_replay():
|
||||
client, server = pair()
|
||||
wire = client.encrypt('你好'.encode())
|
||||
assert server.decrypt(wire) == '你好'.encode()
|
||||
assert client.decrypt(server.encrypt(b'reply')) == b'reply'
|
||||
with pytest.raises(ProtocolError):
|
||||
server.decrypt(wire)
|
||||
with pytest.raises(ProtocolError):
|
||||
server.decrypt(client.encrypt(b'next'))
|
||||
|
||||
|
||||
def test_tamper_direction_and_connection_isolation():
|
||||
client, server = pair()
|
||||
wire = client.encrypt(b'command')
|
||||
with pytest.raises(ProtocolError):
|
||||
client.decrypt(wire)
|
||||
with pytest.raises(ProtocolError):
|
||||
server.decrypt(wire[:-1] + bytes([wire[-1] ^ 1]))
|
||||
_, other = pair()
|
||||
with pytest.raises(ProtocolError):
|
||||
other.decrypt(wire)
|
||||
|
||||
|
||||
def test_invalid_hello_and_json():
|
||||
handshake = Handshake()
|
||||
hello = json.loads(handshake.hello)
|
||||
hello['protocol_major'] = 2
|
||||
with pytest.raises(ProtocolError, match='UNSUPPORTED_VERSION'):
|
||||
handshake.finish(json.dumps(hello).encode(), server=True)
|
||||
for raw in (b'{"a":1,"a":2}', b'{"a":NaN}', b'[]', b'\xff'):
|
||||
with pytest.raises(ProtocolError):
|
||||
json_object(raw)
|
||||
|
||||
|
||||
def test_shared_golden_vector():
|
||||
path = Path(__file__).resolve().parents[2] / '移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/resources/protocol-v1.json'
|
||||
vector = json.loads(path.read_text(encoding='utf-8'))
|
||||
def raw(key):
|
||||
return bytes.fromhex(vector[key])
|
||||
client = Handshake(private_key=ec.derive_private_key(int(vector['client_scalar']), ec.SECP256R1()), random_bytes=raw('client_random'))
|
||||
server = Handshake(private_key=ec.derive_private_key(int(vector['server_scalar']), ec.SECP256R1()), random_bytes=raw('server_random'))
|
||||
assert client.hello == raw('client_hello')
|
||||
assert server.hello == raw('server_hello')
|
||||
cc, sc = client.finish(server.hello, server=False), server.finish(client.hello, server=True)
|
||||
assert cc.transcript == raw('transcript')
|
||||
assert cc.encrypt(raw('request')) == raw('client_record')
|
||||
assert sc.encrypt(raw('response')) == raw('server_record')
|
||||
assert sc.decrypt(raw('client_record')) == raw('request')
|
||||
assert cc.decrypt(raw('server_record')) == raw('response')
|
||||
assert [p.hex() for p in fragments(3, 0, raw('client_record'), 23)] == vector['fragments_mtu23']
|
||||
@@ -0,0 +1,74 @@
|
||||
import json
|
||||
import pytest
|
||||
from app.mobile.protocol import Handshake, ProtocolError, fragments
|
||||
from app.mobile.session import SessionManager
|
||||
|
||||
|
||||
def connection(clock=lambda: 0):
|
||||
calls = []
|
||||
manager = SessionManager(lambda method, params: calls.append(method) or {}, lambda: {"device_id": "test"}, clock=clock)
|
||||
assert manager.connect('a')
|
||||
assert not manager.connect('b')
|
||||
client = Handshake()
|
||||
result = None
|
||||
for part in fragments(1, 0, client.hello, 247):
|
||||
result = manager.accept('a', part)
|
||||
cipher = client.finish(result[1], server=False)
|
||||
return manager, cipher, calls
|
||||
|
||||
|
||||
def request(manager, cipher, mid, rid, method, params=None):
|
||||
raw = json.dumps(dict(id=rid, method=method, params=params or {})).encode()
|
||||
for part in fragments(3, mid, cipher.encrypt(raw), 247):
|
||||
result = manager.accept('a', part)
|
||||
return json.loads(cipher.decrypt(result[1]))
|
||||
|
||||
|
||||
def test_no_control_before_open():
|
||||
manager, cipher, calls = connection()
|
||||
with pytest.raises(ProtocolError):
|
||||
request(manager, cipher, 1, '1', 'settings.patch')
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_single_owner_duplicate_and_disconnect():
|
||||
manager, cipher, calls = connection()
|
||||
assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'})['ok']
|
||||
assert manager.status()['client_name'] == '手机'
|
||||
assert request(manager, cipher, 2, '2', 'content.play')['ok']
|
||||
assert not request(manager, cipher, 3, '2', 'content.play')['ok']
|
||||
assert calls == ['content.play']
|
||||
manager.disconnect('b')
|
||||
assert manager.status()['connected']
|
||||
manager.disconnect('a')
|
||||
assert not manager.status()['connected']
|
||||
assert manager.connect('b')
|
||||
|
||||
|
||||
def test_handshake_and_idle_deadlines():
|
||||
now = [0]
|
||||
manager, cipher, _ = connection(lambda: now[0])
|
||||
now[0] = 10
|
||||
assert manager.expired()
|
||||
manager, cipher, _ = connection(lambda: now[0])
|
||||
request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'})
|
||||
now[0] = 29
|
||||
assert not manager.expired()
|
||||
now[0] = 30
|
||||
assert manager.expired()
|
||||
|
||||
|
||||
def test_negotiated_receive_mtu_resets_on_disconnect():
|
||||
manager, cipher, _ = connection()
|
||||
assert manager.peer_mtu == 23
|
||||
assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机', 'receive_mtu': 247})['ok']
|
||||
assert manager.peer_mtu == 247
|
||||
manager.disconnect('a')
|
||||
assert manager.peer_mtu == 23
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mtu', [0, 22, 518, True, '247'])
|
||||
def test_invalid_receive_mtu_never_opens_session(mtu):
|
||||
manager, cipher, _ = connection()
|
||||
assert not request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机', 'receive_mtu': mtu})['ok']
|
||||
assert not manager.opened
|
||||
@@ -133,6 +133,31 @@ def test_failed_install_restores_system_bytes_and_running_state(host):
|
||||
assert ['systemctl', 'start', c.FRP] in host
|
||||
|
||||
|
||||
def test_mobile_rollback_restores_files_masks_and_running_state(host):
|
||||
journal, record = make_journal()
|
||||
record['mobile_services'] = {
|
||||
'aw859-bluetooth.service': {'enabled_state': 'masked', 'active': False},
|
||||
'bluetooth.service': {'enabled_state': 'enabled', 'active': True},
|
||||
}
|
||||
record['files']['bluetooth-original'] = None
|
||||
for name in c.MOBILE_FILES:
|
||||
c.FILES[name].write_bytes(b'changed-by-mobile-install')
|
||||
c.restore_components(journal, record)
|
||||
assert not c.FILES['bluetooth-original'].exists()
|
||||
for name in set(c.MOBILE_FILES) - {'bluetooth-original'}:
|
||||
assert c.FILES[name].read_bytes() == ('old-' + name).encode()
|
||||
assert ['systemctl', 'mask', 'aw859-bluetooth.service'] in host
|
||||
assert ['systemctl', 'enable', 'bluetooth.service'] in host
|
||||
assert ['systemctl', 'start', 'bluetooth.service'] in host
|
||||
assert ['systemctl', 'start', 'aw859-bluetooth.service'] not in host
|
||||
|
||||
|
||||
def test_legacy_component_journal_does_not_touch_bluetooth(host):
|
||||
journal, record = make_journal()
|
||||
c.restore_components(journal, record)
|
||||
assert not any(unit in args for args in host for unit in c.MOBILE_SERVICES)
|
||||
|
||||
|
||||
def test_power_loss_between_data_renames_recovers_original(host):
|
||||
journal, record = make_journal()
|
||||
(c.DATA/'user-content').write_bytes(b'unchanged')
|
||||
@@ -181,6 +206,35 @@ def test_normal_migration_does_not_touch_components(host):
|
||||
assert host == []
|
||||
|
||||
|
||||
def test_mobile_only_patch_enters_durable_component_transaction(host, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
source = c.RELEASES / '1.1.2-mobilepatch'
|
||||
source.mkdir()
|
||||
(source / 'VERSION').write_text('1.1.2', encoding='utf-8')
|
||||
(source / 'scripts').mkdir()
|
||||
(source / 'scripts/install_mobile_bluetooth.sh').write_text('# test fixture', encoding='utf-8')
|
||||
candidate = c.DATA.with_name('matrix-screen-controller.ota.mobilepatch')
|
||||
candidate.mkdir()
|
||||
c.RUNTIME.mkdir(parents=True, exist_ok=True)
|
||||
(c.RUNTIME / 'ota-request.json').write_text(json.dumps({
|
||||
'job_id': 'mobilepatch', 'current_version': '1.1.1', 'target_version': '1.1.2'
|
||||
}), encoding='utf-8')
|
||||
monkeypatch.setattr(c.os, 'geteuid', lambda: 0, raising=False)
|
||||
monkeypatch.setattr(c.os, 'uname', lambda: SimpleNamespace(machine='aarch64'), raising=False)
|
||||
monkeypatch.setattr(c, 'check_installed', lambda *args: None)
|
||||
monkeypatch.setattr(c, 'protect_runtime', lambda: None)
|
||||
monkeypatch.setattr(c, 'mirror_permissions', lambda *args: None)
|
||||
# Host test verifies transaction ordering; POSIX durability is covered by
|
||||
# real Linux lifecycle checks, not Windows read-only descriptor fsync.
|
||||
monkeypatch.setattr(c.os, 'fsync', lambda *args: None)
|
||||
c.begin(source, candidate)
|
||||
record = json.loads((c.DATA / c.JOURNAL / 'state.json').read_text(encoding='utf-8'))
|
||||
assert set(record['mobile_services']) == set(c.MOBILE_SERVICES)
|
||||
assert (candidate / c.JOURNAL / 'state.json').is_file()
|
||||
assert ['/bin/sh', str(source / 'scripts/install_mobile_bluetooth.sh')] in host
|
||||
assert not any('install_frpc_system.sh' in str(arg) for args in host for arg in args)
|
||||
|
||||
|
||||
def release_project(tmp_path, monkeypatch):
|
||||
from scripts import export_release as exporter
|
||||
source = tmp_path / 'software'
|
||||
|
||||
@@ -114,7 +114,7 @@ def test_unavailable_cpufreq_is_reported_without_fake_success(tmp_path):
|
||||
"effective": False,
|
||||
"current_governors": {},
|
||||
"restore_governors": {},
|
||||
"last_error": None,
|
||||
"last_error": "当前内核未提供 CPU 调频策略;请检查候选内核是否回退",
|
||||
}
|
||||
with pytest.raises(PerformanceModeError, match="unavailable"):
|
||||
manager.transact(True, lambda _enabled: None)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import pytest
|
||||
@@ -110,3 +111,30 @@ def test_repository_contract_has_example_and_ignored_private_file():
|
||||
assert hygiene.EXAMPLE_CREDENTIAL in paths
|
||||
assert hygiene.PRIVATE_CREDENTIAL not in paths
|
||||
assert hygiene._check_repository_contract(paths, staged=False) == []
|
||||
|
||||
|
||||
def test_mobile_markers_are_checked_even_without_board_credentials(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(hygiene, "WORKSPACE_ROOT", tmp_path)
|
||||
private = tmp_path.joinpath(*hygiene.PRIVATE_MOBILE_REGISTRATION.parts)
|
||||
private.parent.mkdir(parents=True)
|
||||
private.write_text(json.dumps({"devices": [{"serial": "FAKE-SERIAL-PRIVATE-123"}]}), encoding="utf-8")
|
||||
markers = hygiene._private_value_markers()
|
||||
assert "FAKE-SERIAL-PRIVATE-123" in markers
|
||||
assert hygiene._text_issues(PurePosixPath("docs/report.md"), "FAKE-SERIAL-PRIVATE-123", markers)
|
||||
|
||||
|
||||
def test_corrupt_mobile_registration_fails_closed(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(hygiene, "WORKSPACE_ROOT", tmp_path)
|
||||
private = tmp_path.joinpath(*hygiene.PRIVATE_MOBILE_REGISTRATION.parts)
|
||||
private.parent.mkdir(parents=True)
|
||||
private.write_text("broken", encoding="utf-8")
|
||||
with pytest.raises(hygiene.HygieneError, match="移动端真实登记损坏"):
|
||||
hygiene._private_value_markers()
|
||||
|
||||
|
||||
def test_mobile_private_and_signing_files_are_rejected():
|
||||
findings = hygiene.audit_paths(
|
||||
[hygiene.PRIVATE_MOBILE_REGISTRATION, PurePosixPath("keys/debug.keystore")],
|
||||
scan_binary=False,
|
||||
)
|
||||
assert len(findings) == 2
|
||||
|
||||
@@ -7,11 +7,25 @@ import pytest
|
||||
from app.config.store import ConfigStore
|
||||
from app.display.service import DisplayService
|
||||
from app.display.wifi_indicator import render_wifi_indicator
|
||||
from app.network.manager import MockNetworkManager
|
||||
from app.network.manager import MockNetworkManager, NmcliNetworkManager
|
||||
from app.network.service import WifiNetworkService, validate_wifi_settings
|
||||
from app.network.store import WifiConfigError, WifiConfigStore
|
||||
|
||||
|
||||
@pytest.mark.parametrize('raw_dns', ['192.0.2.1,192.0.2.2', '192.0.2.1\n192.0.2.2', '192.0.2.1, 192.0.2.2\n192.0.2.1'])
|
||||
def test_nmcli_multiple_dns_can_roundtrip_without_password(monkeypatch, raw_dns):
|
||||
backend = NmcliNetworkManager()
|
||||
values = {'802-11-wireless.ssid': 'test-network', 'ipv4.method': 'manual',
|
||||
'ipv4.addresses': '192.0.2.20/24', 'ipv4.gateway': '192.0.2.1',
|
||||
'ipv4.dns': raw_dns, '802-11-wireless-security.key-mgmt': 'wpa-psk'}
|
||||
monkeypatch.setattr(backend, '_connection_value', lambda uuid, field, **kwargs: values.get(field, ''))
|
||||
saved = backend.read_saved('test-profile')
|
||||
assert saved['dns_servers'] == ['192.0.2.1', '192.0.2.2']
|
||||
checked = validate_wifi_settings(saved, previous=saved)
|
||||
assert checked['dns_servers'] == saved['dns_servers']
|
||||
assert checked['password'] is None
|
||||
|
||||
|
||||
UUID = "12345678-1234-5678-1234-567812345678"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user