Source code for tests.test_cooldown

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

import gc
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest
from click_extra import Duration

from meta_package_manager.cli_maintenance import cooldown_permits
from meta_package_manager.managers.gem import Gem
from meta_package_manager.managers.homebrew import Homebrew
from meta_package_manager.managers.npm import NPM
from meta_package_manager.managers.pacman import _YAY_COOLDOWN_INIT_LUA, Yay
from meta_package_manager.managers.pip import Pip
from meta_package_manager.managers.pipx import Pipx
from meta_package_manager.managers.uv import UV, UVX
from meta_package_manager.version import parse_version

"""Test the supply-chain release-age cooldown feature.

Parsing of the `--cooldown` value itself (friendly, ISO 8601 and RFC 3339
shapes, calendar-unit rejection, zero / future-timestamp disabling) is covered
upstream by click-extra's `Duration` test suite: only the cooldown semantics
built on top of the parsed `timedelta` are exercised here."""


[docs] @pytest.mark.parametrize( ("cooldown_input", "release_iso", "should_pass"), ( # idna 3.6 was published 2023-11-25. # idna 3.7 was published 2024-04-11. # Anchor cooldown=P7D against a fake "today" of 2024-04-15: # cutoff = 2024-04-08, so 3.6 (older) passes but 3.7 (Apr 11) is blocked. # We assert the cutoff math directly since it does not depend on a real # registry call. ("P7D", "2023-11-25T00:00:00Z", True), # idna 3.6: older than cutoff. ("P7D", "2024-04-11T00:00:00Z", False), # idna 3.7: newer than cutoff. ), ) def test_release_anchored_cutoff_math( cooldown_input, release_iso, should_pass, monkeypatch ): """Verify the cooldown cutoff blocks fresh releases and lets older ones pass. Pattern borrowed from astral-sh/uv#19475: anchor against real upstream release timestamps (idna 3.6 / 3.7 on PyPI) and a frozen 'today' so the arithmetic stays deterministic. """ fake_now = datetime(2024, 4, 15, tzinfo=timezone.utc) cooldown = Duration().convert(cooldown_input, None, None) assert cooldown is not None cutoff = fake_now - cooldown release_time = datetime.fromisoformat(release_iso.replace("Z", "+00:00")) assert (release_time <= cutoff) is should_pass
[docs] @pytest.mark.parametrize( ("manager_class", "env_var"), ( (NPM, "npm_config_min-release-age"), (Pip, "PIP_UPLOADED_PRIOR_TO"), (Pipx, "PIP_UPLOADED_PRIOR_TO"), (UV, "UV_EXCLUDE_NEWER"), (UVX, "UV_EXCLUDE_NEWER"), ), ) def test_supported_managers_advertise_cooldown(manager_class, env_var): manager = manager_class() assert manager.supports_cooldown is True assert manager.cooldown_env_var == env_var
[docs] @pytest.mark.parametrize("manager_class", (Gem, Homebrew)) def test_unsupported_managers_lack_cooldown(manager_class): manager = manager_class() assert manager.supports_cooldown is False assert manager.cooldown_env_var is None # Even with a cooldown set, no environment is injected. manager.cooldown = timedelta(days=7) assert manager.cooldown_env() == {}
[docs] @pytest.mark.parametrize("manager_class", (Pip, Pipx, UV, UVX)) def test_timestamp_based_managers_inject_cutoff(manager_class): """Managers whose env var expects an RFC 3339 cutoff timestamp.""" manager = manager_class() # No cooldown means no injection. assert manager.cooldown_env() == {} # A cooldown injects exactly one env var holding an RFC 3339 cutoff in the past. manager.cooldown = timedelta(days=7) env = manager.cooldown_env() assert set(env) == {manager.cooldown_env_var} cutoff = datetime.fromisoformat(env[manager.cooldown_env_var]) now = datetime.now(tz=timezone.utc) # The cutoff sits roughly one cooldown in the past (a minute of slack). assert abs((now - cutoff) - timedelta(days=7)) < timedelta(minutes=1)
[docs] @pytest.mark.parametrize( ("cooldown", "expected_days"), ( (timedelta(days=7), "7"), (timedelta(weeks=2), "14"), (timedelta(days=1), "1"), # Sub-day durations round up to avoid silently disabling the gate. (timedelta(hours=12), "1"), (timedelta(hours=25), "2"), (timedelta(seconds=1), "1"), ), ) def test_npm_injects_integer_days(cooldown, expected_days): """npm's `min-release-age` expects an integer count of days, not a timestamp.""" manager = NPM() assert manager.cooldown_env() == {} manager.cooldown = cooldown assert manager.cooldown_env() == {"npm_config_min-release-age": expected_days}
[docs] def test_yay_advertises_cooldown_while_idle(): """While idle, yay reports its structural capability without a version probe. The import-time `COOLDOWN_SUPPORTED_MANAGERS` help text reads this for every manager, so it must never shell out to `yay --version`. """ manager = Yay() assert manager.cooldown is None assert manager.supports_cooldown is True assert manager.cooldown_env_var == "XDG_CONFIG_HOME" assert "version" not in manager.__dict__ assert manager.cooldown_env() == {}
[docs] @pytest.mark.parametrize( ("version", "supported"), ( ("13.0.0", True), ("13.0.2", True), ("14.1.0", True), ("12.4.2", False), ("11.0.0", False), ), ) def test_yay_cooldown_version_gate(version, supported): """yay can enforce a cooldown only from v13.0.0, when its Lua hooks landed.""" manager = Yay() # Bypass the live `yay --version` probe with a parsed fake. manager.__dict__["version"] = parse_version(version) manager.cooldown = timedelta(days=7) assert manager.supports_cooldown is supported # An unsupported version must never inject a half-configured environment. if not supported: assert manager.cooldown_env() == {}
[docs] def test_yay_cooldown_undetectable_version(): """A yay whose version cannot be parsed cannot enforce a cooldown.""" manager = Yay() manager.__dict__["version"] = None manager.cooldown = timedelta(days=7) assert manager.supports_cooldown is False assert manager.cooldown_env() == {}
[docs] def test_yay_cooldown_policy_gates_both_paths(): """The generated policy gates upgrades and installs off the same cutoff.""" assert 'create_autocmd("UpgradeSelect"' in _YAY_COOLDOWN_INIT_LUA assert 'create_autocmd("AURPreInstall"' in _YAY_COOLDOWN_INIT_LUA assert "MPM_COOLDOWN_EPOCH" in _YAY_COOLDOWN_INIT_LUA
[docs] @pytest.mark.parametrize( ("env", "expected"), ( ({"XDG_CONFIG_HOME": "/xdg", "HOME": "/home/u"}, Path("/xdg/yay")), ({"HOME": "/home/u"}, Path("/home/u/.config/yay")), ({}, None), ), ) def test_yay_user_config_dir(env, expected, monkeypatch): """The real config dir follows yay's own XDG_CONFIG_HOME over HOME precedence.""" monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.delenv("HOME", raising=False) for key, value in env.items(): monkeypatch.setenv(key, value) assert Yay._user_yay_config_dir() == expected
[docs] def test_yay_cooldown_overlay(tmp_path, monkeypatch): """An active cooldown on a recent yay builds a lossless XDG_CONFIG_HOME overlay.""" user_cfg = tmp_path / ".config" / "yay" user_cfg.mkdir(parents=True) (user_cfg / "config.json").write_text("{}") monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) manager = Yay() manager.__dict__["version"] = parse_version("13.0.2") manager.cooldown = timedelta(days=7) env = manager.cooldown_env() assert set(env) == {"XDG_CONFIG_HOME", "MPM_COOLDOWN_EPOCH", "MPM_YAY_USER_DIR"} assert env["MPM_YAY_USER_DIR"] == str(user_cfg) # The cutoff sits roughly one cooldown in the past (a minute of slack). epoch = env["MPM_COOLDOWN_EPOCH"] assert epoch is not None cutoff = datetime.fromtimestamp(int(epoch), tz=timezone.utc) now = datetime.now(tz=timezone.utc) assert abs((now - cutoff) - timedelta(days=7)) < timedelta(minutes=1) # The overlay carries the generated policy and a symlink back to the real config. xdg_home = env["XDG_CONFIG_HOME"] assert xdg_home is not None overlay = Path(xdg_home) / "yay" assert (overlay / "init.lua").read_text(encoding="UTF-8") == _YAY_COOLDOWN_INIT_LUA config_link = overlay / "config.json" assert config_link.is_symlink() assert config_link.resolve() == (user_cfg / "config.json").resolve() # The directory is memoized, so repeated calls reuse the same overlay. assert manager.cooldown_env()["XDG_CONFIG_HOME"] == env["XDG_CONFIG_HOME"]
[docs] def test_yay_cooldown_overlay_without_user_config(tmp_path, monkeypatch): """With no user config present, the overlay omits the config.json symlink.""" monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) manager = Yay() manager.__dict__["version"] = parse_version("13.0.2") manager.cooldown = timedelta(days=7) env = manager.cooldown_env() xdg_home = env["XDG_CONFIG_HOME"] assert xdg_home is not None overlay = Path(xdg_home) / "yay" assert (overlay / "init.lua").is_file() assert not (overlay / "config.json").exists()
[docs] def test_yay_cooldown_overlay_survives_manager_gc(tmp_path, monkeypatch): """The overlay must outlive the Yay instance's garbage collection. Cleanup is registered with `atexit`, not `weakref.finalize(self, ...)`: yay re-reads `init.lua` mid-run, so a GC-tied removal could delete the overlay before yay finishes and silently fail the gate open. """ monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) manager = Yay() manager.__dict__["version"] = parse_version("13.0.2") manager.cooldown = timedelta(days=7) xdg_home = manager.cooldown_env()["XDG_CONFIG_HOME"] assert xdg_home is not None init_lua = Path(xdg_home) / "yay" / "init.lua" assert init_lua.is_file() del manager gc.collect() assert init_lua.is_file(), "overlay deleted when the Yay instance was collected"
[docs] def test_yay_cooldown_epoch_clamped_to_zero(tmp_path, monkeypatch): """A cooldown reaching before 1970 clamps the epoch to 0. A negative Unix timestamp parses back to nil in yay's gopher-lua, which silently drops the gate; epoch 0 keeps the floor effective. """ monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) manager = Yay() manager.__dict__["version"] = parse_version("13.0.2") manager.cooldown = timedelta(days=40000) # ~109 years: cutoff predates 1970. assert manager.cooldown_env()["MPM_COOLDOWN_EPOCH"] == "0"
[docs] def test_yay_cooldown_no_recursion_when_version_resolved_lazily(tmp_path, monkeypatch): """Resolving the version lazily under an active cooldown must not recurse. `version` runs `yay --version` through `run()`, which injects `cooldown_env()`, which consults `supports_cooldown` -> `version`. The re-entrancy guard must break that loop. Regression for a live `RecursionError` the pre-seeded-version tests above could not catch. """ monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) monkeypatch.setenv("HOME", str(tmp_path)) manager = Yay() manager.cooldown = timedelta(days=7) # Make the binary look present so the `version` property actually probes. manager.__dict__["supported"] = True manager.__dict__["executable"] = True def fake_run_cli(*args, **kwargs): # The real run() injects cooldown_env() on every CLI call; mimic that so the # version probe re-enters cooldown_env -> supports_cooldown -> version. manager.cooldown_env() return "yay v13.0.2 - libalpm v13.0.1" monkeypatch.setattr(manager, "run_cli", fake_run_cli) # Must terminate and resolve correctly (used to raise RecursionError). assert str(manager.version) == "13.0.2" assert manager.supports_cooldown is True assert set(manager.cooldown_env()) == { "XDG_CONFIG_HOME", "MPM_COOLDOWN_EPOCH", "MPM_YAY_USER_DIR", }
[docs] def test_cooldown_permits_without_cooldown(): manager = Homebrew() manager.cooldown = None assert cooldown_permits(manager) is True
[docs] def test_cooldown_permits_supported_manager(): manager = UV() manager.cooldown = timedelta(days=7) assert cooldown_permits(manager) is True
[docs] def test_cooldown_permits_blocks_unsupported(caplog): caplog.set_level(logging.WARNING) manager = Homebrew() manager.cooldown = timedelta(days=7) manager.require_cooldown_support = True assert cooldown_permits(manager) is False assert "cannot enforce" in caplog.text assert "--allow-unsupported-managers" in caplog.text
[docs] def test_cooldown_permits_allows_unsupported_on_opt_in(caplog): caplog.set_level(logging.WARNING) manager = Homebrew() manager.cooldown = timedelta(days=7) manager.require_cooldown_support = False assert cooldown_permits(manager) is True assert "without the supply-chain safeguard" in caplog.text
[docs] def test_cli_rejects_invalid_cooldown(invoke): result = invoke("--cooldown", "bogus", "managers") assert result.exit_code == 2 assert "not a valid duration" in result.stderr