from __future__ import annotations import hashlib import json from pathlib import Path import shutil import subprocess import zipfile import pytest from app.ota.policy import check_upgrade, export_bundle, package_format, read_policy from app.ota.versioning import SoftwareVersion as V from app.ota.package import build_package, inspect_package, OtaPackageError from scripts import ota_components as c @pytest.mark.parametrize('current,target', [ ('1.0.0', '1.0.3'), ('1.0.3', '1.1.0'), ('1.0.6', '1.1.0'), ('1.1.0', '1.1.3'), ('1.1.0', '1.2.0'), ('1.1.8', '1.2.0'), ('1.2.0', '1.2.9'), ]) def test_allowed_upgrade_paths(current, target): check_upgrade(V.parse(current), V.parse(target)) @pytest.mark.parametrize('current,target,required', [ ('1.0.6', '1.1.1', '1.1.0'), ('1.0.3', '1.2.0', '1.1.0'), ('1.0.0', '1.2.5', '1.1.0'), ('1.1.3', '1.2.1', '1.2.0'), ]) def test_cannot_skip_software_install(current, target, required): with pytest.raises(ValueError, match=required): check_upgrade(V.parse(current), V.parse(target)) def source_and_wheels(root, version): source = root / 'source' source.mkdir() (source / 'VERSION').write_text(version, encoding='utf-8') wheels = root / 'wheels' wheels.mkdir() (wheels / 'SHA256SUMS').write_text('test', encoding='utf-8') return source, wheels def test_bridge_v1_and_patch_v2_with_identical_manifest_shape(tmp_path): for version in ('1.1.0', '1.1.3'): root = tmp_path / version root.mkdir() source, wheels = source_and_wheels(root, version) package = root / 'release.ota' build_package(source, wheels, package, version=V.parse(version), release_notes='test') with zipfile.ZipFile(package) as z: manifest = json.loads(z.read('manifest.json')) assert manifest['format_version'] == package_format(V.parse(version)) if version == '1.1.3': with pytest.raises(OtaPackageError, match='1.1.0'): inspect_package(package, current_version=V.parse('1.0.6')) inspect_package(package, current_version=V.parse('1.1.0')) def test_patch_rejects_system_binary_payload(tmp_path): source, wheels = source_and_wheels(tmp_path, '1.1.1') with pytest.raises(OtaPackageError, match='普通补丁'): build_package(source, wheels, tmp_path/'bad.ota', version=V.parse('1.1.1'), release_notes='test', system_dependencies=wheels) def test_dependency_change_requires_checkpoint(tmp_path): source = tmp_path / 'source' source.mkdir() binary = tmp_path / 'deps/frp/v/frpc' binary.parent.mkdir(parents=True) binary.write_bytes(b'original') policy = {'schema_version': 1, 'checkpoints': [{'version': '1.1.0', 'components': { 'frpc': {'version': 'v', 'bundle': 'frp/v', 'sha256': hashlib.sha256(b'original').hexdigest()}}}]} (source/'UPGRADE_POLICY.json').write_text(json.dumps(policy), encoding='utf-8') assert export_bundle(source, tmp_path/'deps', V.parse('1.0.6'), V.parse('1.1.0')) == binary.parent assert export_bundle(source, tmp_path/'deps', V.parse('1.1.0'), V.parse('1.1.3')) is None binary.write_bytes(b'replaced') with pytest.raises(ValueError, match='依赖发生变化'): export_bundle(source, tmp_path/'deps', V.parse('1.1.0'), V.parse('1.1.3')) policy['checkpoints'][0]['version'] = '1.2.0' (source/'UPGRADE_POLICY.json').write_text(json.dumps(policy), encoding='utf-8') with pytest.raises(ValueError, match='consecutive'): read_policy(source) @pytest.fixture def host(tmp_path, monkeypatch): for name, relative in [('DATA', 'var/data'), ('TARGET', 'opt/current'), ('RELEASES', 'opt/releases'), ('RUNTIME', 'run'), ('HELPER', 'opt/recover.py'), ('RECOVERY_UNIT', 'etc/recovery.service'), ('WORK_ROOT', 'opt/work'), ('ACCOUNT_POLICY', 'etc/account'), ('RUNTIME_GUARD', 'run/systemd/service.d/guard.conf')]: monkeypatch.setattr(c, name, tmp_path / relative) files = {name: tmp_path / 'etc' / name for name in c.FILES} monkeypatch.setattr(c, 'FILES', files) c.DATA.mkdir(parents=True) c.RELEASES.mkdir(parents=True) for name, path in files.items(): path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(('old-'+name).encode()) commands = [] def run(args, **kwargs): commands.append(args) return subprocess.CompletedProcess(args, 0, b'', b'') monkeypatch.setattr(c, 'run', run) monkeypatch.setattr(c, 'sync_directory', lambda path: None) monkeypatch.setattr(c, 'apply_metadata', lambda *args: None) return commands def make_journal(): journal = c.DATA / c.JOURNAL journal.mkdir(parents=True) record = {'job_id': 'job123', 'version': '1.1.0', 'old_version': '1.0.6', 'accepted_at': '2026-09-07T00:00:00Z', 'previous_target': None, 'permissions': {}, 'active': True, 'enabled': True, 'files': {name: {'mode': 0o644, 'uid': 0, 'gid': 0} for name in c.FILES}} for name, path in c.FILES.items(): (journal/name).write_bytes(path.read_bytes()) (journal/'state.json').write_text(json.dumps(record), encoding='utf-8') return journal, record def test_failed_install_restores_system_bytes_and_running_state(host): journal, record = make_journal() for name in ('frpc', 'frpc-unit', 'frpc-dropin'): c.FILES[name].write_bytes(b'new') c.restore_components(journal, record) for name in ('frpc', 'frpc-unit', 'frpc-dropin'): assert c.FILES[name].read_bytes() == ('old-'+name).encode() assert ['systemctl', 'enable', c.FRP] in host assert ['systemctl', 'start', c.FRP] in host def test_power_loss_between_data_renames_recovers_original(host): journal, record = make_journal() (c.DATA/'user-content').write_bytes(b'unchanged') backup = c.DATA.with_name('matrix-screen-controller.rollback.job123') c.DATA.rename(backup) assert c.locate_journal() == backup / c.JOURNAL recovered = c.recover_application(backup/c.JOURNAL, record) assert recovered == c.DATA/c.JOURNAL assert (c.DATA/'user-content').read_bytes() == b'unchanged' assert not backup.exists() def test_legacy_failure_restores_ota_unit_too(host): journal, record = make_journal() c.FILES['ota-unit'].write_bytes(b'new-worker-unit') c.recover_application(journal, record) assert c.FILES['ota-unit'].read_bytes() == b'old-ota-unit' def test_success_cleanup_keeps_installed_component_and_user_data(host): journal, record = make_journal() c.TARGET.mkdir(parents=True) (c.TARGET/'VERSION').write_text('1.1.0', encoding='utf-8') result = {'last_result': {'status': 'success', 'target_version': '1.1.0', 'installed_at': '2026-09-07T01:00:00Z'}} (c.DATA/'ota/state.json').write_text(json.dumps(result), encoding='utf-8') (c.DATA/'user-content').write_bytes(b'keep') c.FILES['frpc'].write_bytes(b'new-verified') c.finish() assert c.FILES['frpc'].read_bytes() == b'new-verified' assert (c.DATA/'user-content').read_bytes() == b'keep' assert not journal.exists() assert ['systemctl', 'stop', c.FRP] not in host def test_old_success_result_cannot_commit_new_transaction(host): journal, record = make_journal() c.TARGET.mkdir(parents=True) (c.TARGET/'VERSION').write_text('1.1.0', encoding='utf-8') (c.DATA/'ota/state.json').write_text(json.dumps({'last_result': { 'status': 'success', 'target_version': '1.1.0', 'installed_at': '2026-09-01T00:00:00Z'}}), encoding='utf-8') assert not c.committed(record) def test_normal_migration_does_not_touch_components(host): c.begin(c.TARGET, c.DATA) assert host == [] def release_project(tmp_path, monkeypatch): from scripts import export_release as exporter source = tmp_path / 'software' source.mkdir() (source / 'VERSION').write_text('1.0.6\n', encoding='utf-8') (source / 'FEATURE_UPDATED_AT').write_text('2026-09-07T20:00+08:00\n', encoding='utf-8') deps = tmp_path / '发布更新相关/其他依赖' binary = deps / 'frp/v/frpc' binary.parent.mkdir(parents=True) binary.write_bytes(b'frpc') digest = hashlib.sha256(b'frpc').hexdigest() (binary.parent/'SHA256SUMS').write_text(f'{digest} frpc\n', encoding='utf-8') wheels = deps / 'aarch64-py311' wheels.mkdir() (wheels/'SHA256SUMS').write_text('fixture', encoding='utf-8') policy = {'schema_version': 1, 'checkpoints': [{'version': '1.1.0', 'components': { 'frpc': {'version': 'v', 'bundle': 'frp/v', 'sha256': digest}}}]} (source/'UPGRADE_POLICY.json').write_text(json.dumps(policy), encoding='utf-8') (tmp_path/'发布记录.json').write_text('{"schema_version":1,"releases":[]}', encoding='utf-8') monkeypatch.setattr(exporter, 'SOURCE_ROOT', source) return exporter, source def test_export_checkpoint_then_patch_without_repeating_binary(tmp_path, monkeypatch): import sys import tarfile exporter, source = release_project(tmp_path, monkeypatch) monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'test']) assert exporter.main() == 0 assert (source/'VERSION').read_text(encoding='utf-8').strip() == '1.1.0' monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--notes', 'patch']) assert exporter.main() == 0 artifact = next((tmp_path/'发布更新相关/OTA数据包/1.1.1').glob('*.ota')) with zipfile.ZipFile(artifact) as z, z.open('payload.tar.gz') as f, tarfile.open(fileobj=f, mode='r|gz') as t: assert not any('system-dependencies' in item.name for item in t) records = json.loads((tmp_path/'发布记录.json').read_text(encoding='utf-8'))['releases'] assert [r['package_kind'] for r in records] == ['software-install', 'application'] assert all(r['artifact_path'].startswith('发布更新相关/OTA数据包/') for r in records) assert all((tmp_path / r['artifact_path']).is_file() for r in records) assert (source/'FEATURE_UPDATED_AT').read_text(encoding='utf-8') == '2026-09-07T20:00+08:00\n' def test_failed_export_does_not_consume_version(tmp_path, monkeypatch): import sys exporter, source = release_project(tmp_path, monkeypatch) original = (tmp_path/'发布记录.json').read_bytes() original_version = (source/'VERSION').read_bytes() monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'test']) def fail(*args): raise OSError('injected history write failure') monkeypatch.setattr(exporter, '_atomic_json', fail) with pytest.raises(OSError, match='injected'): exporter.main() assert (source/'VERSION').read_bytes() == original_version assert (tmp_path/'发布记录.json').read_bytes() == original assert not list((tmp_path/'发布更新相关/OTA数据包').iterdir()) assert not (tmp_path/'.release-export.lock').exists() def test_recovery_error_keeps_durable_journal(host, monkeypatch): journal, record = make_journal() def fail(*args): raise OSError('injected restore failure') monkeypatch.setattr(c, 'restore_components', fail) with pytest.raises(OSError, match='injected'): c.finish() assert (journal/'state.json').is_file() def test_provisioned_account_does_not_need_sudo_group(host, monkeypatch): import sys from types import SimpleNamespace c.FILES['frpc-dropin'].unlink() c.ACCOUNT_POLICY.write_text('maintainer ALL=(ALL:ALL) ALL\n', encoding='utf-8') monkeypatch.setitem(sys.modules, 'pwd', SimpleNamespace(getpwnam=lambda name: SimpleNamespace(pw_uid=1001))) monkeypatch.setitem(sys.modules, 'grp', SimpleNamespace()) assert c.account() == 'maintainer' c.ACCOUNT_POLICY.write_text('invalid', encoding='utf-8') with pytest.raises(RuntimeError, match='账户配置无效'): c.account() @pytest.mark.parametrize('fail_commit', [False, True]) def test_same_version_repair_promotes_exact_candidate_and_preserves_old_artifact(tmp_path, monkeypatch, fail_commit): import sys from scripts.repair_ota_release import promote exporter, source = release_project(tmp_path, monkeypatch) monkeypatch.setattr(sys, 'argv', ['export', 'ota', '--version', '1.1.0', '--notes', 'original']) exporter.main() artifact=tmp_path/'发布更新相关/OTA数据包/1.1.0/matrix-screen-controller-1.1.0.ota' old=artifact.read_bytes();old_history=(tmp_path/'发布记录.json').read_bytes() (source/'fix.py').write_text('FIXED = True\n',encoding='utf-8') candidate=tmp_path/'candidate.ota' build_package(source,tmp_path/'发布更新相关/其他依赖/aarch64-py311',candidate, version=V.parse('1.1.0'),release_notes='repair', system_dependencies=tmp_path/'发布更新相关/其他依赖/frp/v') report=tmp_path/'validation.json' report.write_text(json.dumps({'package_sha256':hashlib.sha256(candidate.read_bytes()).hexdigest(), 'installed_version':'1.1.0','status':'success','runtime_lifecycle_passed':True, 'user_data_preserved':True,'frp_state_preserved':True,'transaction_cleanup_passed':True}),encoding='utf-8') if fail_commit: def fail(*args): raise OSError('injected publication failure') monkeypatch.setattr(exporter,'_atomic_json',fail) with pytest.raises(OSError,match='publication'): promote(source,candidate,report,'repair') assert artifact.read_bytes()==old assert (tmp_path/'发布记录.json').read_bytes()==old_history else: promote(source,candidate,report,'repair') assert artifact.read_bytes()==candidate.read_bytes() history=json.loads((tmp_path/'发布记录.json').read_text(encoding='utf-8')) assert len(history['releases'])==1 assert len(history['releases'][0]['repairs'])==1 assert (source/'VERSION').read_text(encoding='utf-8').strip()=='1.1.0' archived=list((tmp_path/'各种归档').glob('*/原安装包/*.ota')) assert len(archived)==1 and archived[0].read_bytes()==old