diff options
Diffstat (limited to '')
-rw-r--r-- | debian/tests/control | 22 | ||||
-rwxr-xr-x | debian/tests/debconf | 189 | ||||
-rwxr-xr-x | debian/tests/python | 179 |
3 files changed, 390 insertions, 0 deletions
diff --git a/debian/tests/control b/debian/tests/control new file mode 100644 index 0000000..e3bde44 --- /dev/null +++ b/debian/tests/control @@ -0,0 +1,22 @@ +Tests: debconf +Depends: python3, tzdata +Restrictions: allow-stderr needs-root + +Tests: python +Depends: python3 (>= 3.9), tzdata, tzdata-legacy +Restrictions: allow-stderr + +Test-command: NO_LEGACY_TIMEZONES=1 debian/tests/python +Depends: python3 (>= 3.9), tzdata +Restrictions: allow-stderr +Features: test-name=python_no_legacy + +Test-command: debian/test_timezone_conversions -d /var/lib/dpkg/info/ +Depends: python3, python3-debian, tzdata, tzdata-legacy +Restrictions: allow-stderr superficial +Features: test-name=test_timezone_conversions + +Test-command: debian/test_timezone_conversions -d /var/lib/dpkg/info/ --all-selectable +Depends: python3, python3-debian, tzdata +Restrictions: allow-stderr superficial +Features: test-name=test_timezone_conversions_no_legacy diff --git a/debian/tests/debconf b/debian/tests/debconf new file mode 100755 index 0000000..6daac04 --- /dev/null +++ b/debian/tests/debconf @@ -0,0 +1,189 @@ +#!/usr/bin/python3 + +# Author: Benjamin Drung <bdrung@ubuntu.com> + +"""Test debconf configuration.""" + +import contextlib +import os +import pathlib +import re +import subprocess +import sys +import typing +import unittest + + +class TestDebconf(unittest.TestCase): + """Test debconf configuration.""" + + etc_localtime = pathlib.Path("/etc/localtime") + etc_timezone = pathlib.Path("/etc/timezone") + + def setUp(self) -> None: + self.orig_timezone = self._get_timezone() + with contextlib.suppress(FileNotFoundError): + self.etc_timezone.unlink() + + def tearDown(self) -> None: + self._set_timezone(self.orig_timezone) + + @staticmethod + def _call_debconf_set_selections(selections: str) -> None: + subprocess.run( + ["debconf-set-selections"], check=True, encoding="utf-8", input=selections + ) + + @staticmethod + def _call_dpkg_reconfigure() -> None: + subprocess.run( + ["dpkg-reconfigure", "--frontend", "noninteractive", "tzdata"], check=True + ) + + @staticmethod + def _get_debconf_selections() -> dict[str, str]: + debconf_show = subprocess.run( + ["debconf-show", "tzdata"], capture_output=True, check=True, text=True + ) + debconf_re = re.compile("^[ *] ([^:]+): *(.*)$", flags=re.MULTILINE) + return dict(debconf_re.findall(debconf_show.stdout)) + + def _get_selection(self) -> str: + selections = self._get_debconf_selections() + area = selections["tzdata/Areas"] + zone = selections[f"tzdata/Zones/{area}"] + return f"{area}/{zone}" + + def _get_timezone(self) -> str: + absolute_path = self.etc_localtime.parent / self.etc_localtime.readlink() + timezone = pathlib.Path(os.path.normpath(absolute_path)) + return str(timezone.relative_to("/usr/share/zoneinfo")) + + def _set_timezone(self, timezone: typing.Union[pathlib.Path, str]) -> None: + with contextlib.suppress(FileNotFoundError): + self.etc_localtime.unlink() + if isinstance(timezone, str): + target = pathlib.Path("/usr/share/zoneinfo") / timezone + else: + target = timezone + self.etc_localtime.symlink_to(target) + + @staticmethod + def _reset_debconf() -> None: + subprocess.run( + ["debconf-communicate", "tzdata"], + check=True, + encoding="utf-8", + env={"DEBIAN_FRONTEND": "noninteractive"}, + input="RESET tzdata/Areas\nRESET tzdata/Zones/Etc\n", + stdout=subprocess.DEVNULL, + ) + + def test_broken_symlink(self) -> None: + """Test pointing /etc/localtime to an invalid location.""" + self._set_timezone(pathlib.Path("/bin/sh")) + self._reset_debconf() + self._call_dpkg_reconfigure() + self.assertEqual(self._get_timezone(), "Etc/UTC") + self.assertEqual(self._get_selection(), "Etc/UTC") + + def test_broken_symlink_but_debconf_preseed(self) -> None: + """Test broken /etc/localtime but existing debconf answers.""" + self._set_timezone(pathlib.Path("/bin/sh")) + self._call_debconf_set_selections( + "tzdata tzdata/Areas select Pacific\n" + "tzdata tzdata/Zones/Pacific select Yap\n" + ) + self._call_dpkg_reconfigure() + self.assertEqual(self._get_timezone(), "Pacific/Yap") + self.assertEqual(self._get_selection(), "Pacific/Yap") + + def test_etc_localtime_precedes_debconf_preseed(self) -> None: + """Test dpkg-reconfigure uses /etc/localtime over preseed.""" + self._set_timezone("Asia/Jerusalem") + self._call_debconf_set_selections( + "tzdata tzdata/Areas select Australia\n" + "tzdata tzdata/Zones/Australia select Sydney\n" + ) + self._call_dpkg_reconfigure() + self.assertEqual(self._get_timezone(), "Asia/Jerusalem") + self.assertEqual(self._get_selection(), "Asia/Jerusalem") + + def test_default_to_utc(self) -> None: + """Test dpkg-reconfigure defaults to Etc/UTC.""" + self.etc_localtime.unlink() + self._reset_debconf() + self._call_dpkg_reconfigure() + self.assertEqual(self._get_timezone(), "Etc/UTC") + self.assertEqual(self._get_selection(), "Etc/UTC") + + def test_reconfigure_creates_etc_timezone(self) -> None: + """Test dpkg-reconfigure does create /etc/timezone.""" + self._set_timezone("America/New_York") + self._call_dpkg_reconfigure() + self.assertEqual(self._get_timezone(), "America/New_York") + self.assertTrue(self.etc_timezone.exists()) + self.assertEqual(self._get_selection(), "America/New_York") + + def test_reconfigure_updates_etc_timezone(self) -> None: + """Test dpkg-reconfigure updates existing /etc/timezone.""" + self._set_timezone("Europe/Oslo") + self.etc_timezone.write_bytes(b"") + self._call_dpkg_reconfigure() + self.assertEqual(self._get_timezone(), "Europe/Oslo") + self.assertEqual(self.etc_timezone.read_text(encoding="utf-8"), "Europe/Oslo\n") + self.assertEqual(self._get_selection(), "Europe/Oslo") + + def test_reconfigure_fallback_to_etc_timezone(self) -> None: + """Test dpkg-reconfigure reads /etc/timezone for missing /etc/localtime.""" + self.etc_localtime.unlink() + self.etc_timezone.write_text("Europe/Kiev\n", encoding="utf-8") + self._call_dpkg_reconfigure() + self.assertEqual(self._get_timezone(), "Europe/Kyiv") + self.assertEqual(self.etc_timezone.read_text(encoding="utf-8"), "Europe/Kyiv\n") + self.assertEqual(self._get_selection(), "Europe/Kyiv") + + def test_reconfigure_symlinked_timezone(self) -> None: + """Test dpkg-reconfigure for symlinked timezone.""" + self._set_timezone("Arctic/Longyearbyen") + self._call_dpkg_reconfigure() + self.assertEqual(self._get_timezone(), "Arctic/Longyearbyen") + self.assertEqual(self._get_selection(), "Arctic/Longyearbyen") + + def test_relative_symlink(self) -> None: + """Test relative symlink /etc/localtime.""" + timezone = pathlib.Path("../usr/share/zoneinfo/Europe/Berlin") + self._set_timezone(timezone) + self._call_dpkg_reconfigure() + self.assertEqual(self._get_timezone(), "Europe/Berlin") + self.assertEqual(self.etc_localtime.readlink(), timezone) + self.assertEqual(self._get_selection(), "Europe/Berlin") + + def test_update_obsolete_timezone(self) -> None: + """Test updating obsolete timezone to current one.""" + self._set_timezone("Mideast/Riyadh88") + self._call_dpkg_reconfigure() + self.assertEqual(self._get_timezone(), "Asia/Riyadh") + self.assertEqual(self._get_selection(), "Asia/Riyadh") + + def test_preesed(self) -> None: + """Test preseeding answers with non-existing /etc/localtime.""" + self.etc_localtime.unlink() + self._call_debconf_set_selections( + "tzdata tzdata/Areas select Europe\n" + "tzdata tzdata/Zones/Europe select Berlin\n" + ) + self._call_dpkg_reconfigure() + self.assertEqual(self._get_timezone(), "Europe/Berlin") + self.assertEqual(self._get_selection(), "Europe/Berlin") + + +def main() -> None: + """Run unit tests in verbose mode.""" + argv = sys.argv.copy() + argv.insert(1, "-v") + unittest.main(argv=argv) + + +if __name__ == "__main__": + main() diff --git a/debian/tests/python b/debian/tests/python new file mode 100755 index 0000000..25044a9 --- /dev/null +++ b/debian/tests/python @@ -0,0 +1,179 @@ +#!/usr/bin/python3 + +# Author: Benjamin Drung <bdrung@ubuntu.com> + +"""Test timezones using Python's zoneinfo module.""" + +import datetime +import os +import pathlib +import re +import sys +import typing +import unittest +import zoneinfo + +ROOT_DIR = pathlib.Path(__file__).parent.parent.parent +HAS_LEGACY_TIMEZONES = "NO_LEGACY_TIMEZONES" not in os.environ + + +def read_backwards_links(backwards_file: pathlib.Path) -> dict[str, str]: + """Read backwards compatibility links from the upstream backwards file.""" + backwards_links = {} + for line in backwards_file.read_text(encoding="utf-8").splitlines(): + match = re.match(r"^Link\t(?P<target>\S+)\t+(?P<link_name>\S+)", line) + if not match: + continue + backwards_links[match.group("link_name")] = match.group("target") + return backwards_links + + +class TestZoneinfo(unittest.TestCase): + """Test timezones using Python's zoneinfo module.""" + + def _hours(self, delta: typing.Optional[datetime.timedelta]) -> int: + assert delta is not None + total_seconds = int(delta.total_seconds()) + self.assertEqual(total_seconds % 3600, 0) + return total_seconds // 3600 + + def test_available_timezones_count(self) -> None: + """Test available_timezones() count to be reasonable.""" + zones = len(zoneinfo.available_timezones()) + expected_zones = 485 + if HAS_LEGACY_TIMEZONES: + expected_zones = 597 + self.assertGreaterEqual(zones, expected_zones, "less zones than 2023c-8") + self.assertLess( + zones, round(expected_zones * 1.1), ">10% more zones than 2023c-8" + ) + + def test_daylight_saving_transition(self) -> None: + """Test daylight saving time transition from Python documentation.""" + tzinfo = zoneinfo.ZoneInfo("America/Los_Angeles") + date = datetime.datetime(2020, 10, 31, 12, tzinfo=tzinfo) + self.assertEqual(date.tzname(), "PDT") + next_day = date + datetime.timedelta(days=1) + self.assertEqual(next_day.tzname(), "PST") + + def _assert_equal_zones_at_date( + self, + date: datetime.datetime, + timezone1: zoneinfo.ZoneInfo, + timezone2: zoneinfo.ZoneInfo, + ) -> None: + date1 = date.replace(tzinfo=timezone1) + date2 = date.replace(tzinfo=timezone2) + self.assertEqual(date1 - date2, datetime.timedelta(seconds=0)) + self.assertEqual(date1.tzname(), date2.tzname()) + + def _assert_equal_zones( + self, timezone1: zoneinfo.ZoneInfo, timezone2: zoneinfo.ZoneInfo + ) -> None: + """Test timezones to be heuristically equal regardless of the name.""" + october_2020 = datetime.datetime(2020, 10, 31, 12) + self._assert_equal_zones_at_date(october_2020, timezone1, timezone2) + july_2021 = datetime.datetime(2021, 7, 3, 12) + self._assert_equal_zones_at_date(july_2021, timezone1, timezone2) + + @unittest.skipIf(os.environ.get("PYTHONTZPATH"), "requires installed tzdata") + def test_localtime(self) -> None: + """Test 'localtime' timezone.""" + localtime = pathlib.Path("/etc/localtime") + zone = str(localtime.resolve().relative_to("/usr/share/zoneinfo")) + tzinfo = zoneinfo.ZoneInfo("localtime") + self._assert_equal_zones(tzinfo, zoneinfo.ZoneInfo(zone)) + + def _test_timezone(self, zone: str) -> None: + """Test zone to load, have a name, and have a reasonable offset.""" + tzinfo = zoneinfo.ZoneInfo(zone) + self.assertEqual(str(tzinfo), zone) + date = datetime.datetime(2020, 10, 31, 12, tzinfo=tzinfo) + + tzname = date.tzname() + assert tzname is not None + self.assertGreaterEqual(len(tzname), 3, tzname) + self.assertLessEqual(len(tzname), 5, tzname) + + utc_offset = date.utcoffset() + assert utc_offset is not None + self.assertEqual(int(utc_offset.total_seconds()) % 900, 0) + self.assertLessEqual(utc_offset, datetime.timedelta(hours=14)) + + def test_pre_1970_timestamps(self) -> None: + """Test pre-1970 timestamps of Berlin and Oslo being different.""" + berlin = zoneinfo.ZoneInfo("Europe/Berlin") + date = datetime.datetime(1960, 7, 1, tzinfo=berlin) + self.assertEqual(self._hours(date.utcoffset()), 1) + oslo = zoneinfo.ZoneInfo("Europe/Oslo") + self.assertEqual(self._hours(date.replace(tzinfo=oslo).utcoffset()), 2) + + def test_post_1970_symlinks_consistency(self) -> None: + """Test that post-1970 symlinks are consistent with pre-1970 timezones. + + Building tzdata with PACKRATDATA=backzone will result in separate + time zones for time zones that differ only before 1970. These time + zones should behave identical after 1970. Building tzdata without + PACKRATDATA=backzone will result in one of the time zones become a + symlink to the other time zone. + """ + links = read_backwards_links(ROOT_DIR / "backward") + for link_name, target in links.items(): + with self.subTest(f"{link_name} -> {target}"): + try: + tz_link = zoneinfo.ZoneInfo(link_name) + except zoneinfo.ZoneInfoNotFoundError: + if not HAS_LEGACY_TIMEZONES: + continue + raise + tz_target = zoneinfo.ZoneInfo(target) + now = datetime.datetime.now() + self._assert_equal_zones_at_date(now, tz_link, tz_target) + future = now + datetime.timedelta(days=30 * 6) + self._assert_equal_zones_at_date(future, tz_link, tz_target) + + def test_timezones(self) -> None: + """Test all zones to load, have a name, and have a reasonable offset.""" + for zone in zoneinfo.available_timezones(): + with self.subTest(zone=zone): + self._test_timezone(zone) + + def test_2022g(self) -> None: + """Test new zone America/Ciudad_Juarez from 2022g release.""" + tzinfo = zoneinfo.ZoneInfo("America/Ciudad_Juarez") + date = datetime.datetime(2022, 12, 1, tzinfo=tzinfo) + self.assertEqual(self._hours(date.utcoffset()), -7) + + def test_2023a(self) -> None: + """Test Egypt uses DST again from 2023a release.""" + tzinfo = zoneinfo.ZoneInfo("Africa/Cairo") + date = datetime.datetime(2023, 4, 28, 12, 0, tzinfo=tzinfo) + self.assertEqual(self._hours(date.utcoffset()), 3) + + def test_2023c(self) -> None: + """Test Lebanon's reverted DST delay from 2023c release.""" + tzinfo = zoneinfo.ZoneInfo("Asia/Beirut") + date = datetime.datetime(2023, 4, 2, tzinfo=tzinfo) + self.assertEqual(self._hours(date.utcoffset()), 3) + + def test_2023d(self) -> None: + """Test 2023d release: Vostok being on +05 from 2023-12-18 on.""" + tzinfo = zoneinfo.ZoneInfo("Antarctica/Vostok") + date = datetime.datetime(2023, 12, 19, tzinfo=tzinfo) + self.assertEqual(self._hours(date.utcoffset()), 5) + + def test_2024a(self) -> None: + """Test 2024a release: Kazakhstan being on +05 from 2024-03-01 on.""" + tzinfo = zoneinfo.ZoneInfo("Asia/Almaty") + date = datetime.datetime(2024, 3, 2, tzinfo=tzinfo) + self.assertEqual(self._hours(date.utcoffset()), 5) + +def main() -> None: + """Run unit tests in verbose mode.""" + argv = sys.argv.copy() + argv.insert(1, "-v") + unittest.main(argv=argv) + + +if __name__ == "__main__": + main() |