summaryrefslogtreecommitdiffstats
path: root/debian/tests
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--debian/tests/control22
-rwxr-xr-xdebian/tests/debconf189
-rwxr-xr-xdebian/tests/python179
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()