292 lines
12 KiB
Python
292 lines
12 KiB
Python
#!/usr/local/autopkg/python
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import os.path
|
|
import plistlib
|
|
import posixpath
|
|
import unittest
|
|
import zipfile
|
|
from copy import deepcopy
|
|
from io import BytesIO
|
|
from tempfile import TemporaryDirectory
|
|
from typing import Any, Dict
|
|
from unittest import mock
|
|
from unittest.mock import patch
|
|
|
|
from autopkglib import VarDict
|
|
from autopkglib.Versioner import UNKNOWN_VERSION, ProcessorError, Versioner
|
|
|
|
|
|
def patch_open(data: bytes, **kwargs) -> mock._patch:
|
|
def _new_mock():
|
|
omock = mock.MagicMock(name="open", spec="open")
|
|
omock.side_effect = lambda *args, **kwargs: BytesIO(data)
|
|
return omock
|
|
|
|
return patch("builtins.open", new_callable=_new_mock, **kwargs)
|
|
|
|
|
|
TEST_VERSION_DEFAULT_KEY: str = "CFBundleShortVersionString"
|
|
TEST_VERSION_DEFAULT: str = "1.2.3"
|
|
TEST_VERSION_CUSTOM_KEY: str = "com.someapp.customversion"
|
|
TEST_VERSION_CUSTOM: str = "3.2.1"
|
|
|
|
TEST_VERSION_PLIST: bytes = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CFBundleShortVersionString</key>
|
|
<string>{TEST_VERSION_DEFAULT}</string>
|
|
<key>{TEST_VERSION_CUSTOM_KEY}</key>
|
|
<string>{TEST_VERSION_CUSTOM}</string>
|
|
</dict>
|
|
</plist>""".encode(
|
|
"utf-8"
|
|
)
|
|
|
|
TEST_NO_VERSION_PLIST: bytes = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
</dict>
|
|
</plist>""".encode(
|
|
"utf-8"
|
|
)
|
|
|
|
|
|
class TestVersioner(unittest.TestCase):
|
|
"""Test class for Versioner Processor."""
|
|
|
|
def setUp(self):
|
|
self.maxDiff: int = 100000
|
|
self.tempdir = TemporaryDirectory()
|
|
self.good_env: Dict[str, Any] = {
|
|
"input_plist_path": "dummy_path",
|
|
"plist_version_key": TEST_VERSION_DEFAULT_KEY,
|
|
"RECIPE_CACHE_DIR": self.tempdir.name,
|
|
}
|
|
self.bad_env: Dict[str, Any] = {}
|
|
self.processor = Versioner(data=deepcopy(self.good_env))
|
|
self.addCleanup(self.tempdir.cleanup)
|
|
|
|
def tearDown(self):
|
|
pass
|
|
|
|
def _mkpath(self, *parts: str) -> str:
|
|
"""Returns a path into the per testcase temporary directory.
|
|
On POSIX-y platforms the paths are sensible. On Windows they will non-standard
|
|
because they will use the format `C:/path/to/tmpdir/file.txt` instead of the
|
|
conventional `C:\\path\\....`. This is due to the interaction of code written
|
|
only for macOS and code written to be cross-platform."""
|
|
return posixpath.normpath(os.path.join(self.tempdir.name, *parts))
|
|
|
|
def _run_direct_plist(
|
|
self, plist: bytes, mock_dmg: mock.Mock, mock_plist: mock.Mock
|
|
):
|
|
"""Find version in specified plist file."""
|
|
mock_dmg.return_value = (self.processor.env["input_plist_path"], "", "")
|
|
mock_plist.return_value = plistlib.loads(plist)
|
|
with patch("os.path.exists", return_value=True):
|
|
self.processor.process()
|
|
|
|
@patch("autopkglib.Versioner.load_plist_from_file")
|
|
@patch("autopkglib.Versioner.parsePathForDMG")
|
|
def test_no_fail_if_good_env(self, mock_dmg, mock_plist):
|
|
"""The processor should not raise any exceptions if run normally."""
|
|
self._run_direct_plist(TEST_VERSION_PLIST, mock_dmg, mock_plist)
|
|
|
|
@patch("autopkglib.Versioner.load_plist_from_file")
|
|
@patch("autopkglib.Versioner.parsePathForDMG")
|
|
def test_find_cfbundle_short_version(self, mock_dmg, mock_plist):
|
|
"""The processor should find version in default `CFBundleShortVersionString`."""
|
|
self._run_direct_plist(TEST_VERSION_PLIST, mock_dmg, mock_plist)
|
|
self.assertEqual(self.processor.env["version"], TEST_VERSION_DEFAULT)
|
|
|
|
@patch("autopkglib.Versioner.load_plist_from_file")
|
|
@patch("autopkglib.Versioner.parsePathForDMG")
|
|
def test_find_custom_version(self, mock_dmg, mock_plist):
|
|
"""The processor should find version under key specified by `plist_version_key`."""
|
|
self.processor.env["plist_version_key"] = TEST_VERSION_CUSTOM_KEY
|
|
self._run_direct_plist(TEST_VERSION_PLIST, mock_dmg, mock_plist)
|
|
self.assertEqual(self.processor.env["version"], TEST_VERSION_CUSTOM)
|
|
|
|
@patch("autopkglib.Versioner.load_plist_from_file")
|
|
@patch("autopkglib.Versioner.parsePathForDMG")
|
|
def test_no_version_found(self, mock_dmg, mock_plist):
|
|
"""The processor should not find version if plist misses it."""
|
|
self._run_direct_plist(TEST_NO_VERSION_PLIST, mock_dmg, mock_plist)
|
|
self.assertEqual(self.processor.env["version"], UNKNOWN_VERSION)
|
|
|
|
@patch("os.path.exists", return_value=True)
|
|
@patch.object(Versioner, "_read_from_zip", return_value={})
|
|
@patch.object(Versioner, "_read_from_dmg", return_value={})
|
|
def test_read_auto_detect(self, mock_dmg, mock_zip, mock_exists):
|
|
"""File type auto detection"""
|
|
mock_deserializer = mock.MagicMock()
|
|
|
|
zip_inner_path = self._mkpath("archive.zip", "dummy", "file.txt")
|
|
dmg_inner_path = self._mkpath("image.dmg", "dummy", "file2.txt")
|
|
real_path = self._mkpath("regular", "dummy", "file3.txt")
|
|
for path in (
|
|
real_path,
|
|
zip_inner_path,
|
|
dmg_inner_path,
|
|
):
|
|
with self.subTest(path=path):
|
|
self.processor._read_auto_detect(
|
|
path=path,
|
|
skip_single_root_dir=False,
|
|
deserializer=mock_deserializer,
|
|
)
|
|
mock_deserializer.assert_called_once_with(real_path)
|
|
mock_exists.assert_called_once_with(real_path)
|
|
mock_zip.assert_called_once_with(
|
|
zip_inner_path, False, mock_deserializer, self.processor.ZIP_EXTENSIONS
|
|
)
|
|
mock_dmg.assert_called_once_with(dmg_inner_path, mock_deserializer)
|
|
|
|
def test_version_from_image(self):
|
|
"""Inner image-like (DMG/ISO) paths work"""
|
|
|
|
@patch("os.path.exists", return_value=True)
|
|
@patch.object(Versioner, "_read_from_zip")
|
|
@patch.object(Versioner, "unmount")
|
|
@patch.object(Versioner, "mount")
|
|
# @patch("autopkglib.Versioner.load_plist_from_file")
|
|
@patch_open(TEST_VERSION_PLIST)
|
|
def test_for_extension(
|
|
ext: str, mock_plist, mock_mount, mock_unmount, mock_zip, mock_exists
|
|
):
|
|
mount_path: str = self._mkpath("dmg_mount")
|
|
plist_path: str = self._mkpath(f"fake{ext}/dir/version.plist")
|
|
dmg_path: str = self._mkpath(f"fake{ext}")
|
|
mock_mount.return_value = mount_path
|
|
self.processor.env["input_plist_path"] = plist_path
|
|
result: Dict[str, Any] = self.processor.process()
|
|
mock_zip.assert_not_called()
|
|
mock_exists.assert_called_once_with(
|
|
os.path.normpath(os.path.join(mount_path, "dir/version.plist"))
|
|
)
|
|
mock_mount.assert_called_once_with(dmg_path)
|
|
mock_unmount.assert_called_once_with(dmg_path)
|
|
mock_plist.assert_called_once_with(
|
|
os.path.normpath(os.path.join(mount_path, "dir/version.plist")), "rb"
|
|
)
|
|
self.assertIn("version", result)
|
|
self.assertEqual(TEST_VERSION_DEFAULT, result["version"])
|
|
|
|
for ext_case in self.processor.DMG_EXTENSIONS:
|
|
with self.subTest(image_extension=ext_case):
|
|
test_for_extension(ext_case)
|
|
|
|
@patch("os.path.exists", return_value=False)
|
|
@patch("autopkglib.Versioner._read_from_dmg")
|
|
def test_version_from_zip(self, mock_dmg, mock_exists):
|
|
multi_subdir = list(
|
|
map(
|
|
zipfile.ZipInfo,
|
|
(
|
|
"subdir/",
|
|
"subdir/version.plist",
|
|
"root_level_file.txt",
|
|
"subdir2/",
|
|
"subdir2/boring_file.txt",
|
|
),
|
|
)
|
|
)
|
|
single_subdir = list(
|
|
map(
|
|
zipfile.ZipInfo,
|
|
(
|
|
"subdir/",
|
|
"subdir/version.plist",
|
|
"root_level_file.txt",
|
|
),
|
|
)
|
|
)
|
|
for skip_single_root_dir, plist_file, expected_plist_file, filelist in (
|
|
(False, "subdir/version.plist", "subdir/version.plist", multi_subdir),
|
|
(True, "version.plist", "subdir/version.plist", single_subdir),
|
|
):
|
|
zip_path = self._mkpath("test.zip")
|
|
plist_path = self._mkpath(f"test.zip/{plist_file}")
|
|
self.processor.env["input_plist_path"] = plist_path
|
|
self.processor.env["skip_single_root_dir"] = skip_single_root_dir
|
|
with patch("zipfile.ZipFile") as mock_zipfile:
|
|
mock_zinst = mock_zipfile.return_value
|
|
mock_zinst.open.return_value = BytesIO(TEST_VERSION_PLIST)
|
|
mock_zinst.filelist = filelist
|
|
mock_zinst.infolist.return_value = filelist
|
|
mock_zinst.namelist.return_value = [zi.filename for zi in filelist]
|
|
with self.subTest(
|
|
skip_single_root_dir=skip_single_root_dir,
|
|
plist_file=plist_file,
|
|
filelist=[zi.filename for zi in filelist],
|
|
):
|
|
result: VarDict = self.processor.process()
|
|
mock_zipfile.assert_called_once_with(zip_path)
|
|
mock_zinst.open.assert_called_once_with(expected_plist_file)
|
|
mock_exists.assert_not_called()
|
|
mock_dmg.assert_not_called()
|
|
self.assertIn("version", result)
|
|
self.assertEqual(TEST_VERSION_DEFAULT, result["version"])
|
|
|
|
@patch("os.path.exists", return_value=False)
|
|
@patch.object(Versioner, "_read_from_dmg")
|
|
@patch("zipfile.ZipFile", autospec=True)
|
|
def test_multi_root_zip(self, mock_zipfile, mock_dmg, mock_exists):
|
|
"""Raises ProcessorError when skip_single_root_dir=True and extra dir exists"""
|
|
zip_path = self._mkpath("test.zip")
|
|
plist_path = self._mkpath("test.zip/version.plist")
|
|
self.processor.env["input_plist_path"] = plist_path
|
|
self.processor.env["skip_single_root_dir"] = True
|
|
mock_zinst = mock_zipfile.return_value
|
|
mock_zinst.infolist.return_value = [
|
|
zipfile.ZipInfo("subdir/"),
|
|
zipfile.ZipInfo("subdir/version.plist"),
|
|
zipfile.ZipInfo("subdir2/"),
|
|
zipfile.ZipInfo("subdir2/file.txt"),
|
|
]
|
|
for zi in mock_zinst.infolist.return_value:
|
|
zi.file_size = 0
|
|
zi.compress_size = 0
|
|
mock_zinst.open.return_value.read.return_value = TEST_VERSION_PLIST
|
|
result: Dict[str, Any] = {}
|
|
with self.assertRaisesRegex(
|
|
ProcessorError, r".*rchive.*has more than one.*at root"
|
|
):
|
|
result = self.processor.process()
|
|
|
|
mock_zipfile.assert_called_once_with(zip_path)
|
|
mock_zinst.open.assert_not_called()
|
|
mock_exists.assert_not_called()
|
|
mock_dmg.assert_not_called()
|
|
self.assertNotIn("version", result)
|
|
|
|
def test_path_missing_raises(self):
|
|
"""Raises ProcessorError when the provided path does not exist."""
|
|
for path in self._mkpath(
|
|
"not_real_version.plist", "archive.zip/nope.txt", "image.dmg/darn.json"
|
|
):
|
|
with self.subTest(path=path):
|
|
with self.assertRaisesRegex(
|
|
ProcessorError,
|
|
f"File.*{self.processor.env['input_plist_path']}.*not found",
|
|
):
|
|
self.processor.process()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|