/
/
/
1"""Tests for the local cliairplay download helper."""
2
3from __future__ import annotations
4
5import hashlib
6import stat
7from pathlib import Path
8
9import pytest
10
11from scripts import fetch_airplay_cli
12
13
14def test_fetch_airplay_cli_installs_verified_platform_binary(
15 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
16) -> None:
17 """
18 Install the matching binary after verifying both release checksums.
19
20 The Dockerfile remains the single source for the development and container pins.
21 """
22 binary = b"cliairplay test binary"
23 asset_name = "cliairplay-macos-arm64"
24 manifest = f"{hashlib.sha256(binary).hexdigest()} {asset_name}\n".encode()
25 _write_dockerfile(tmp_path, manifest)
26 downloads: list[str] = []
27
28 def download(url: str) -> bytes:
29 downloads.append(url)
30 return manifest if url.endswith("/SHA256SUMS") else binary
31
32 monkeypatch.setattr("scripts.fetch_airplay_cli.platform.system", lambda: "Darwin")
33 monkeypatch.setattr("scripts.fetch_airplay_cli.platform.machine", lambda: "arm64")
34 monkeypatch.setattr(fetch_airplay_cli, "_download", download)
35
36 result = fetch_airplay_cli.fetch_airplay_cli(tmp_path)
37
38 expected = tmp_path / fetch_airplay_cli.BINARY_DIR / asset_name
39 assert result == expected
40 assert expected.read_bytes() == binary
41 assert expected.stat().st_mode & stat.S_IXUSR
42 assert downloads == [
43 f"{fetch_airplay_cli.RELEASE_BASE_URL}/v0.1.0/SHA256SUMS",
44 f"{fetch_airplay_cli.RELEASE_BASE_URL}/v0.1.0/{asset_name}",
45 ]
46
47
48def test_fetch_airplay_cli_preserves_existing_binary(
49 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
50) -> None:
51 """
52 Preserve an existing local binary without contacting the release service.
53
54 Developers may intentionally replace the pinned executable with a local build.
55 """
56 destination = tmp_path / fetch_airplay_cli.BINARY_DIR / "cliairplay-linux-x86_64"
57 destination.parent.mkdir(parents=True)
58 destination.write_bytes(b"local build")
59 _write_dockerfile(tmp_path, b"unused manifest")
60 monkeypatch.setattr("scripts.fetch_airplay_cli.platform.system", lambda: "Linux")
61 monkeypatch.setattr("scripts.fetch_airplay_cli.platform.machine", lambda: "x86_64")
62 monkeypatch.setattr(fetch_airplay_cli, "_read_binary_version", lambda _path: None)
63 monkeypatch.setattr(
64 fetch_airplay_cli,
65 "_download",
66 lambda _url: pytest.fail("existing binaries must not trigger a download"),
67 )
68
69 assert fetch_airplay_cli.fetch_airplay_cli(tmp_path) == destination
70 assert destination.read_bytes() == b"local build"
71
72
73def test_fetch_airplay_cli_replaces_older_release_binary(
74 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
75) -> None:
76 """Replace a recognized older release binary with the pinned release."""
77 binary = b"new release"
78 asset_name = "cliairplay-macos-arm64"
79 manifest = f"{hashlib.sha256(binary).hexdigest()} {asset_name}\n".encode()
80 _write_dockerfile(tmp_path, manifest)
81 destination = tmp_path / fetch_airplay_cli.BINARY_DIR / asset_name
82 destination.parent.mkdir(parents=True)
83 destination.write_bytes(b"old release")
84 downloads: list[str] = []
85
86 def download(url: str) -> bytes:
87 downloads.append(url)
88 return manifest if url.endswith("/SHA256SUMS") else binary
89
90 monkeypatch.setattr("scripts.fetch_airplay_cli.platform.system", lambda: "Darwin")
91 monkeypatch.setattr("scripts.fetch_airplay_cli.platform.machine", lambda: "arm64")
92 monkeypatch.setattr(fetch_airplay_cli, "_read_binary_version", lambda _path: (0, 0, 9))
93 monkeypatch.setattr(fetch_airplay_cli, "_download", download)
94
95 assert fetch_airplay_cli.fetch_airplay_cli(tmp_path) == destination
96 assert destination.read_bytes() == binary
97 assert downloads == [
98 f"{fetch_airplay_cli.RELEASE_BASE_URL}/v0.1.0/SHA256SUMS",
99 f"{fetch_airplay_cli.RELEASE_BASE_URL}/v0.1.0/{asset_name}",
100 ]
101
102
103def test_fetch_airplay_cli_rejects_checksum_mismatch(
104 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
105) -> None:
106 """
107 Reject a binary that differs from the pinned release checksum.
108
109 A failed verification must not leave an executable in the provider directory.
110 """
111 asset_name = "cliairplay-linux-aarch64"
112 manifest = f"{hashlib.sha256(b'expected').hexdigest()} {asset_name}\n".encode()
113 _write_dockerfile(tmp_path, manifest)
114 monkeypatch.setattr("scripts.fetch_airplay_cli.platform.system", lambda: "Linux")
115 monkeypatch.setattr("scripts.fetch_airplay_cli.platform.machine", lambda: "arm64")
116 monkeypatch.setattr(
117 fetch_airplay_cli,
118 "_download",
119 lambda url: manifest if url.endswith("/SHA256SUMS") else b"unexpected",
120 )
121
122 with pytest.raises(RuntimeError, match="Checksum mismatch"):
123 fetch_airplay_cli.fetch_airplay_cli(tmp_path)
124
125 assert not (tmp_path / fetch_airplay_cli.BINARY_DIR / asset_name).exists()
126
127
128def test_fetch_airplay_cli_rejects_manifest_digest_mismatch(
129 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
130) -> None:
131 """
132 Reject a checksum manifest that differs from the Dockerfile pin.
133
134 A modified manifest must fail before the executable asset is downloaded.
135 """
136 asset_name = "cliairplay-linux-x86_64"
137 manifest = f"{hashlib.sha256(b'binary').hexdigest()} {asset_name}\n".encode()
138 _write_dockerfile(tmp_path, manifest, manifest_digest="0" * 64)
139 monkeypatch.setattr("scripts.fetch_airplay_cli.platform.system", lambda: "Linux")
140 monkeypatch.setattr("scripts.fetch_airplay_cli.platform.machine", lambda: "x86_64")
141 downloads: list[str] = []
142
143 def download(url: str) -> bytes:
144 downloads.append(url)
145 return manifest
146
147 monkeypatch.setattr(fetch_airplay_cli, "_download", download)
148
149 with pytest.raises(RuntimeError, match="SHA256SUMS digest mismatch"):
150 fetch_airplay_cli.fetch_airplay_cli(tmp_path)
151
152 assert downloads == [f"{fetch_airplay_cli.RELEASE_BASE_URL}/v0.1.0/SHA256SUMS"]
153
154
155def test_read_docker_arg_reports_missing_file(tmp_path: Path) -> None:
156 """Report an unreadable Dockerfile as a clean setup error."""
157 dockerfile = tmp_path / "Dockerfile"
158
159 with pytest.raises(RuntimeError, match=f"Unable to read {dockerfile}"):
160 fetch_airplay_cli._read_docker_arg(dockerfile, "CLIAIRPLAY_VERSION")
161
162
163def test_main_reports_filesystem_error(
164 monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
165) -> None:
166 """Return a clean failure instead of a traceback for filesystem errors."""
167 monkeypatch.setattr(
168 fetch_airplay_cli,
169 "fetch_airplay_cli",
170 lambda _root: (_ for _ in ()).throw(PermissionError("read-only filesystem")),
171 )
172
173 assert fetch_airplay_cli.main() == 1
174 assert capsys.readouterr().err == "ERROR: read-only filesystem\n"
175
176
177@pytest.mark.parametrize(
178 ("system", "machine", "expected"),
179 [
180 ("Darwin", "aarch64", "cliairplay-macos-arm64"),
181 ("Darwin", "x86_64", "cliairplay-macos-x86_64"),
182 ("Linux", "arm64", "cliairplay-linux-aarch64"),
183 ("Linux", "amd64", "cliairplay-linux-x86_64"),
184 ("Windows", "AMD64", None),
185 ("Linux", "riscv64", None),
186 ],
187)
188def test_asset_name(system: str, machine: str, expected: str | None) -> None:
189 """Map supported development platforms to their release asset."""
190 assert fetch_airplay_cli._asset_name(system, machine) == expected
191
192
193@pytest.mark.parametrize(
194 ("value", "expected"),
195 [
196 ("v0.3.0", (0, 3, 0)),
197 ("1.2.3", (1, 2, 3)),
198 ("0.3", None),
199 ("development", None),
200 ],
201)
202def test_parse_release_version(value: str, expected: tuple[int, int, int] | None) -> None:
203 """Parse supported release versions without treating local labels as releases."""
204 assert fetch_airplay_cli._parse_release_version(value) == expected
205
206
207@pytest.mark.parametrize(
208 ("current", "pinned", "expected"),
209 [
210 ((0, 2, 0), "v0.3.0", True),
211 ((0, 3, 0), "v0.3.0", False),
212 ((0, 3, 1), "v0.3.0", False),
213 (None, "v0.3.0", False),
214 ],
215)
216def test_binary_is_outdated(
217 tmp_path: Path,
218 monkeypatch: pytest.MonkeyPatch,
219 current: tuple[int, int, int] | None,
220 pinned: str,
221 expected: bool,
222) -> None:
223 """Only older recognized releases are replaced."""
224 binary_path = tmp_path / "cliairplay"
225 monkeypatch.setattr(fetch_airplay_cli, "_read_binary_version", lambda _path: current)
226
227 assert fetch_airplay_cli._binary_is_outdated(binary_path, pinned) is expected
228
229
230def _write_dockerfile(root: Path, manifest: bytes, *, manifest_digest: str | None = None) -> None:
231 """Write the release pins consumed by the helper."""
232 if manifest_digest is None:
233 manifest_digest = hashlib.sha256(manifest).hexdigest()
234 root.joinpath("Dockerfile").write_text(
235 f"ARG CLIAIRPLAY_VERSION=v0.1.0\nARG CLIAIRPLAY_CHECKSUMS_SHA256={manifest_digest}\n",
236 encoding="utf-8",
237 )
238