Files
nyaterm/scripts/tests/test_windows_msi.py

239 lines
14 KiB
Python

"""Check MSI release contracts; integration tests never install the packages."""
from __future__ import annotations
import copy
import shutil
import subprocess
import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "release"))
import package_native
import verify_native_package
import windows_msi
from scripts.tests.test_verify_native_package import fake_pe
def stage_payload(root: Path, target: str) -> None:
root.mkdir(parents=True)
machine = 0x8664 if target.startswith("x86_64") else 0xAA64
(root / "NyaTerm.exe").write_bytes(fake_pe(machine, subsystem=2, resource_ids=(3, 14, 16)))
for name in package_native.HELPER_BINS:
(root / f"{name}.exe").write_bytes(fake_pe(machine))
(root / "VERSION").write_text("2.0.0\n", encoding="utf-8")
(root / "LICENSE").write_text("test license", encoding="utf-8")
shutil.copy2(package_native.ICON_DIR / "icon.ico", root / "icon.ico")
for relative in package_native.conpty_files(target):
path = root / "conpty" / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(fake_pe(0xAA64 if "arm64" in relative.parts else 0x8664))
(root / "conpty/LICENSE.txt").write_text("ConPTY test license", encoding="utf-8")
class WindowsMsiTests(unittest.TestCase):
def test_only_stable_releases_include_msi(self) -> None:
for version, expected in (("2.0.0", True), ("2.0.0+build-with-hyphen", True),
("2.0.0-preview.1", False), ("2.0.0-rc.1", False),
("main-snapshot", False)):
with self.subTest(version=version):
self.assertEqual(package_native.includes_windows_msi(version), expected)
for target in ("x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"):
names = package_native.artifact_names(target, version)
self.assertEqual(sum(name.endswith(".msi") for name in names), int(expected))
self.assertEqual(len(names), 3 if expected else 2)
def test_msi_version_obeys_three_field_windows_installer_limits(self) -> None:
for version, expected in (("v2.0.0", "2.0.0"), ("2.0.0+build.7", "2.0.0"),
("255.255.65535", "255.255.65535")):
self.assertEqual(package_native.windows_msi_version(version), expected)
for version in ("256.0.0", "2.256.0", "2.0.65536", "2.0.0-preview.9", "main-snapshot"):
with self.subTest(version=version), self.assertRaises(ValueError):
package_native.windows_msi_version(version)
def test_wix_version_is_pinned_and_checked(self) -> None:
with mock.patch.object(windows_msi.shutil, "which", return_value="wix"):
for version, accepted in (("6.0.2+commit\n", True), ("7.0.0\n", False)):
with mock.patch.object(windows_msi.subprocess, "check_output", return_value=version):
if accepted:
self.assertEqual(windows_msi.find_wix(), "wix")
else:
with self.assertRaisesRegex(RuntimeError, "expected WiX 6.0.2"):
windows_msi.find_wix()
def test_component_identity_is_stable_and_product_identity_changes(self) -> None:
self.assertEqual(windows_msi.product_code("2.0.0", "x64"), "2A20BF91-AE77-5C55-B7B5-4EA06FF5C4A0")
self.assertNotEqual(windows_msi.product_code("2.0.0", "x64"),
windows_msi.product_code("2.0.1", "x64"))
self.assertNotEqual(windows_msi.product_code("2.0.0", "x64"),
windows_msi.product_code("2.0.0", "arm64"))
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
staging = root / 'payload with spaces & symbols'
stage_payload(staging, "x86_64-pc-windows-msvc")
components = []
ns = {"w": windows_msi.WIX_NAMESPACE}
for version in ("2.0.0", "2.0.1"):
source = root / f"{version}.wxs"
windows_msi.write_source(staging, source, version, "x64")
xml = ET.parse(source)
components.append({node.attrib["Id"]: node.attrib["Guid"]
for node in xml.findall(".//w:Component", ns)})
files = xml.findall(".//w:File", ns)
self.assertEqual(len(files), sum(path.is_file() for path in staging.rglob("*")))
self.assertTrue(all(Path(node.attrib["Source"]).is_file() for node in files))
removed = xml.find(".//w:RemoveFile[@Id='RemoveLegacyActiveXHelper']", ns)
self.assertIsNotNone(removed)
self.assertEqual(removed.attrib["Name"], "nyaterm-activex-helper.exe")
self.assertEqual(removed.attrib["On"], "install")
self.assertEqual(components[0], components[1])
class WindowsMsiCleanupTests(unittest.TestCase):
def setUp(self) -> None:
self.rows = [
{"FileKey": "RemoveLegacyActiveXHelper", "Component_": "ApplicationComponent",
"FileName": "nyaterm-activex-helper.exe", "DirProperty": "INSTALLDIR", "InstallMode": "1"},
{"FileKey": "RemoveStartMenuFolder", "Component_": "ApplicationComponent",
"FileName": "", "DirProperty": "ApplicationProgramsFolder", "InstallMode": "2"},
]
def test_allows_legacy_helper_and_empty_start_menu_cleanup(self) -> None:
for name in ("nyaterm-activex-helper.exe", "NYATER~1.EXE|nyaterm-activex-helper.exe"):
for rows in (self.rows, list(reversed(self.rows))):
with self.subTest(name=name, order=[row["FileKey"] for row in rows]):
self.rows[0]["FileName"] = name
verify_native_package.verify_windows_msi_cleanup(rows, "ApplicationComponent")
def test_rejects_changed_cleanup_targets_owners_and_install_modes(self) -> None:
for index in range(len(self.rows)):
for field, value in (
("FileKey", "UnexpectedCleanup"), ("Component_", "OtherComponent"),
("DirProperty", "APPDATA"), ("InstallMode", "3"),
("FileName", "*"), ("FileName", "*.exe"), ("FileName", "settings.json"),
("FileName", "../nyaterm-activex-helper.exe"),
("FileName", "*|nyaterm-activex-helper.exe"),
("FileName", "../bad.exe|nyaterm-activex-helper.exe"),
("FileName", "a|b|nyaterm-activex-helper.exe"),
):
changed = copy.deepcopy(self.rows)
changed[index][field] = value
with self.subTest(rule=index, field=field, value=value), self.assertRaises(RuntimeError):
verify_native_package.verify_windows_msi_cleanup(changed, "ApplicationComponent")
for index, name in ((0, ""), (1, "nyaterm-activex-helper.exe")):
changed = copy.deepcopy(self.rows)
changed[index]["FileName"] = name
with self.subTest(rule=index, name=name), self.assertRaises(RuntimeError):
verify_native_package.verify_windows_msi_cleanup(changed, "ApplicationComponent")
def test_requires_exactly_the_two_authorized_cleanup_rules(self) -> None:
for rows in ([], self.rows[:1], self.rows[1:], self.rows + [self.rows[0]],
[self.rows[0], self.rows[0]], [self.rows[1], self.rows[1]]):
with self.subTest(rules=[row["FileKey"] for row in rows]), self.assertRaises(RuntimeError):
verify_native_package.verify_windows_msi_cleanup(rows, "ApplicationComponent")
@unittest.skipUnless(sys.platform == "win32" and shutil.which("wix"), "requires Windows and WiX 6.0.2")
class CompiledWindowsMsiTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temporary = tempfile.TemporaryDirectory(prefix="nyaterm-msi-test-")
cls.addClassCleanup(cls.temporary.cleanup)
cls.root = Path(cls.temporary.name)
cls.packages = {}
for target in ("x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"):
root = cls.root / target
staging = root / "payload with spaces & symbols"
stage_payload(staging, target)
work, dist = root / "work", root / "dist"
work.mkdir()
dist.mkdir()
info = package_native.target_info(target)
with (mock.patch.object(package_native, "WORK_DIR", work),
mock.patch.object(package_native, "DIST_DIR", dist)):
package_native.create_windows_msi(staging, info, "2.0.0", "2.0.0")
path = dist / f"NyaTerm_2.0.0_{info.label}.msi"
cls.packages[target] = (path, windows_msi.read_database(path), staging)
def test_compiles_and_extracts_complete_packages_for_both_architectures(self) -> None:
# Synthetic PEs test cabinet packaging and architecture checks. The real
# application resource build has its own test_windows_resources coverage.
for target, (path, _, _) in self.packages.items():
with (self.subTest(target=target),
mock.patch.object(verify_native_package, "verify_windows_application_version")):
verify_native_package.verify_windows_msi(path, target, "2.0.0")
def test_compiled_database_rejects_wrong_identity_scope_architecture_and_version(self) -> None:
for target, (_, baseline, _) in self.packages.items():
for property_name in ("UpgradeCode", "ProductCode", "ProductVersion", "ALLUSERS"):
changed = copy.deepcopy(baseline)
next(row for row in changed["tables"]["Property"]
if row["Property"] == property_name)["Value"] = "wrong"
with self.subTest(target=target, property=property_name), self.assertRaises(RuntimeError):
verify_native_package.verify_windows_msi_database(changed, target, "2.0.0")
changed = copy.deepcopy(baseline)
changed["template"] = "wrong;1033"
with self.assertRaisesRegex(RuntimeError, "architecture"):
verify_native_package.verify_windows_msi_database(changed, target, "2.0.0")
def test_compiled_database_requires_all_helpers_and_conpty_files(self) -> None:
for target, (_, baseline, _) in self.packages.items():
files = verify_native_package.verify_windows_msi_database(baseline, target, "2.0.0")
for name in (*verify_native_package.helper_filenames(target),
*(f"conpty/{p.as_posix()}" for p in package_native.conpty_files(target))):
changed = copy.deepcopy(baseline)
changed["tables"]["File"] = [row for row in changed["tables"]["File"] if row["File"] != files[name]]
with self.subTest(target=target, missing=name), self.assertRaisesRegex(RuntimeError, "file set mismatch"):
verify_native_package.verify_windows_msi_database(changed, target, "2.0.0")
def test_compiled_database_rejects_unsafe_cleanup_and_upgrade_policy(self) -> None:
target = "x86_64-pc-windows-msvc"
baseline = self.packages[target][1]
for table, field, value in (
("Component", "ComponentId", "wrong"), ("Directory", "DefaultDir", "../data"),
("Upgrade", "Attributes", "0"), ("RemoveFile", "FileName", "*"),
("Shortcut", "Target", "wrong.exe"), ("CustomAction", "Target", "--unsafe-command"),
):
changed = copy.deepcopy(baseline)
changed["tables"][table][0][field] = value
with self.subTest(table=table), self.assertRaises(RuntimeError):
verify_native_package.verify_windows_msi_database(changed, target, "2.0.0")
changed = copy.deepcopy(baseline)
next(row for row in changed["tables"]["InstallExecuteSequence"]
if row["Action"] == "RemoveExistingProducts")["Sequence"] = "6700"
with self.assertRaisesRegex(RuntimeError, "InstallInitialize"):
verify_native_package.verify_windows_msi_database(changed, target, "2.0.0")
changed = copy.deepcopy(baseline)
changed["tables"]["LaunchCondition"] = []
with self.assertRaisesRegex(RuntimeError, "same-version"):
verify_native_package.verify_windows_msi_database(changed, target, "2.0.0")
def test_extracted_payload_rejects_wrong_pe_architecture_and_version(self) -> None:
target = "x86_64-pc-windows-msvc"
for relative, content, message in (
("NyaTerm.exe", fake_pe(0xAA64), "PE machine"),
("nyaterm-vnc-helper.exe", fake_pe(0xAA64), "PE machine"),
("conpty/x64/conpty.dll", fake_pe(0xAA64), "ConPTY PE machine"),
("VERSION", b"2.0.1\n", "installed version"),
):
with self.subTest(file=relative), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
staging = root / "payload"
stage_payload(staging, target)
(staging / relative).write_bytes(content)
source, output = root / "package.wxs", root / "package.msi"
windows_msi.write_source(staging, source, "2.0.0", "x64")
subprocess.run([windows_msi.find_wix(), "build", "-arch", "x64", "-pdbtype", "none",
"-o", str(output), str(source)], check=True)
with (mock.patch.object(verify_native_package, "verify_windows_application_version"),
self.assertRaisesRegex(RuntimeError, message)):
verify_native_package.verify_windows_msi(output, target, "2.0.0")
if __name__ == "__main__":
unittest.main()