enodia-sentinal/tests/test_packaging.py
Luna 1d1dee7f6e
feat(packaging): add desktop launcher entries for both GUI dashboards
The tray applet shipped with a .desktop file but the GUI dashboards had
none, so desktop users had no menu entry for them.

Unlike the tray entry, these carry no autostart key: a dashboard window
should open when the operator asks for it, not at login. Tests assert the
Exec names match the pyproject console scripts and that neither entry can
regain an autostart key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JX86xeoBJVBb16qHkDf53K
2026-07-22 04:35:37 -07:00

199 lines
7.8 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
"""Static checks for package metadata and install hardening."""
import hashlib
import json
import os
import re
import subprocess
import tempfile
import tomllib
import unittest
from pathlib import Path
from enodia_sentinel import __version__
ROOT = Path(__file__).resolve().parents[1]
def _read(path: str) -> str:
return (ROOT / path).read_text()
class TestArchPackaging(unittest.TestCase):
def test_pkgbuild_version_matches_project(self):
pkgbuild = _read("packaging/PKGBUILD")
pyproject = tomllib.loads(_read("pyproject.toml"))
docs_version = json.loads(_read("docs/VERSION.json"))
pkgver = re.search(r"^pkgver=([^\n]+)$", pkgbuild, re.M)
self.assertIsNotNone(pkgver)
self.assertEqual(pkgver.group(1), pyproject["project"]["version"])
self.assertEqual(pkgver.group(1), __version__)
self.assertEqual(docs_version["package_version"], pkgver.group(1))
def test_pkgbuild_installs_hardened_runtime_files(self):
pkgbuild = _read("packaging/PKGBUILD")
for expected in (
"/usr/bin/enodia-sentinel",
"/usr/lib/systemd/system/enodia-sentinel.service",
"/usr/lib/systemd/system/enodia-sentinel-web.service",
"/usr/share/libalpm/hooks/enodia-sentinel-fim.hook",
"/usr/lib/tmpfiles.d/enodia-sentinel.conf",
"/usr/share/doc/$pkgname/PACKAGING.md",
"/usr/share/doc/$pkgname/VERSION.json",
"/usr/share/doc/$pkgname/examples/enodia-sentinel-ebpf.conf",
"/var/log/enodia-sentinel",
):
self.assertIn(expected, pkgbuild)
self.assertIn("backup=('etc/enodia-sentinel.toml')", pkgbuild)
self.assertIn("install -dm750", pkgbuild)
def test_alpm_hook_uses_absolute_sentinel_path(self):
hook = _read("packaging/enodia-sentinel-fim.hook")
self.assertIn("Exec = /usr/bin/enodia-sentinel fim-update", hook)
self.assertNotIn("/usr/bin/env enodia-sentinel", hook)
self.assertIn("When = PostTransaction", hook)
def test_tmpfiles_declares_private_log_dir(self):
tmpfiles = _read("packaging/enodia-sentinel.tmpfiles")
self.assertIn("d /var/log/enodia-sentinel 0750 root root -", tmpfiles)
def test_build_script_uses_clean_package_build(self):
script = _read("packaging/build-package.sh")
self.assertIn("set -eu", script)
self.assertIn("command -v makepkg", script)
self.assertIn("--cleanbuild", script)
def _git_worktree_dirty() -> int:
"""Mirror the dirty computation in release-artifacts.sh so the assertion
holds whether the tree is clean (work committed) or dirty (mid-change)."""
def q(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", *args], cwd=ROOT,
capture_output=True, text=True)
if q("diff", "--quiet").returncode != 0:
return 1
if q("diff", "--cached", "--quiet").returncode != 0:
return 1
if q("ls-files", "--others", "--exclude-standard").stdout.strip():
return 1
return 0
class TestReleaseArtifacts(unittest.TestCase):
def test_release_script_generates_checksummed_artifacts(self):
with tempfile.TemporaryDirectory() as d:
env = os.environ.copy()
env.update({
"DISTDIR": d,
"RELEASE_ALLOW_DIRTY": "1",
"RELEASE_SIGN": "0",
})
result = subprocess.run(
[str(ROOT / "packaging" / "release-artifacts.sh")],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
timeout=30,
check=True,
)
self.assertIn("Signing skipped", result.stdout)
version = tomllib.loads(_read("pyproject.toml"))["project"]["version"]
archive = Path(d) / f"enodia-sentinel-{version}.tar.gz"
manifest = Path(d) / "RELEASE_MANIFEST.json"
checksums = Path(d) / "SHA256SUMS"
self.assertTrue(archive.is_file())
self.assertTrue(manifest.is_file())
self.assertTrue(checksums.is_file())
self.assertFalse((Path(d) / "SHA256SUMS.asc").exists())
data = json.loads(manifest.read_text())
self.assertEqual(data["project"], "enodia-sentinel")
self.assertEqual(data["version"], version)
self.assertEqual(data["dirty"], _git_worktree_dirty())
self.assertIn(archive.name, data["artifacts"])
self.assertIn("SHA256SUMS", data["artifacts"])
expected = {
archive.name: hashlib.sha256(archive.read_bytes()).hexdigest(),
manifest.name: hashlib.sha256(manifest.read_bytes()).hexdigest(),
}
actual = {}
for line in checksums.read_text().splitlines():
digest, name = line.split(maxsplit=1)
actual[name] = digest
self.assertEqual(actual, expected)
def test_release_script_supports_gpg_signing(self):
script = _read("packaging/release-artifacts.sh")
self.assertIn("RELEASE_SIGN", script)
self.assertIn("RELEASE_SIGNING_KEY", script)
self.assertIn("--armor --detach-sign", script)
self.assertIn("SHA256SUMS.asc", script)
class TestSourceInstallPackaging(unittest.TestCase):
def test_makefile_installs_docs_and_tmpfiles(self):
makefile = _read("Makefile")
self.assertIn("TMPFILESDIR := /etc/tmpfiles.d", makefile)
self.assertIn("packaging/enodia-sentinel.tmpfiles", makefile)
self.assertIn("docs/PACKAGING.md", makefile)
self.assertIn("docs/VERSION.json", makefile)
self.assertIn("systemd/enodia-sentinel-ebpf.conf", makefile)
def test_packaging_guide_documents_distro_paths(self):
guide = _read("docs/PACKAGING.md")
for phrase in (
"Arch is the primary packaged target",
"sudo pacman -U enodia-sentinel-*.pkg.tar.zst",
"There is no native `.deb` package yet",
"There is no native RPM package yet",
"Package-manager hooks use absolute paths",
):
self.assertIn(phrase, guide)
class TestTrayDesktopEntry(unittest.TestCase):
def test_desktop_file_launches_tray_entry_point(self):
text = _read("packaging/enodia-sentinel-tray.desktop")
self.assertIn("[Desktop Entry]", text)
self.assertIn("Type=Application", text)
self.assertIn("Exec=enodia-sentinel-tray", text)
self.assertRegex(text, r"(?m)^Name=.*Sentinel")
class TestGuiDesktopEntries(unittest.TestCase):
ENTRIES = {
"packaging/enodia-sentinel-gui.desktop": "enodia-sentinel-gui",
"packaging/enodia-sentinel-gui-qt.desktop": "enodia-sentinel-gui-qt",
}
def test_desktop_files_launch_gui_entry_points(self):
for path, exec_name in self.ENTRIES.items():
with self.subTest(path=path):
text = _read(path)
self.assertIn("[Desktop Entry]", text)
self.assertIn("Type=Application", text)
self.assertRegex(text, rf"(?m)^Exec={exec_name}$")
self.assertRegex(text, r"(?m)^Name=.*Sentinel")
def test_gui_entry_points_match_pyproject_scripts(self):
scripts = tomllib.loads(_read("pyproject.toml"))["project"]["scripts"]
for exec_name in self.ENTRIES.values():
with self.subTest(script=exec_name):
self.assertIn(exec_name, scripts)
def test_gui_entries_are_launchers_not_autostart(self):
# The tray applet is opt-in autostart; the dashboards are menu
# launchers and must never start themselves at login.
for path in self.ENTRIES:
with self.subTest(path=path):
self.assertNotIn("Autostart", _read(path))
if __name__ == "__main__":
unittest.main()