Files
mesh-catalog/modules/pacman/cmd/pacman-tools/pacman_test.go
T
jochen d9336d11d0 pacman: the package manager's configuration, mirrors and cache as a module
Mirrors were generated once and never again and caches never cleaned. The
module holds node-package-manager (hq ADR 0207), declares pacman itself, owns
/etc/pacman.conf whole — [options] cannot take an appended block — with the
union of the enabled repositories and improved options, proven by pacman-conf
in its test, and enables reflector.timer (its config owned) and
paccache.timer. Fifteen tools from a Go bundle; transactions run as transient
units so a call's timeout never kills pacman mid-transaction (to-be 42).
2026-10-04 12:50:20 +02:00

377 lines
14 KiB
Go

package main
import (
"errors"
"strings"
"testing"
"time"
)
const searchOut = `extra/zsh 5.9.2-1 [installed]
A very advanced and programmable command interpreter (shell) for UNIX
extra/ripgrep 15.2.0-1 [installed: 15.1.0-1]
A search tool
core/base-devel 1-2 (base-devel)
Basic tools to build Arch Linux packages
`
func TestSearchReadsHeaderAndDescriptionAndWhatIsInstalled(t *testing.T) {
f := ParseSearch(searchOut)
if len(f) != 3 {
t.Fatalf("%+v", f)
}
if f[0].Repository != "extra" || f[0].Name != "zsh" || !f[0].Installed || f[0].InstalledAs != "5.9.2-1" || !strings.HasPrefix(f[0].Description, "A very advanced") {
t.Fatalf("%+v", f[0])
}
if f[1].InstalledAs != "15.1.0-1" || f[1].Version != "15.2.0-1" {
t.Fatalf("%+v", f[1])
}
if f[2].Installed || len(f[2].Groups) != 1 || f[2].Groups[0] != "base-devel" {
t.Fatalf("%+v", f[2])
}
}
func TestASearchThatFindsNothingIsEmptyAndAFailureIsAnError(t *testing.T) {
m := machine(fake(func(c call) Ran { return Ran{Status: 1} }, nil), 1000)
r, err := m.Search("nothing", 50)
if err != nil || r["count"] != 0 {
t.Fatalf("%v %v", r, err)
}
m = machine(fake(func(c call) Ran { return Ran{Status: 1, Stderr: "error: failed to initialize alpm library\n"} }, nil), 1000)
if _, err := m.Search("x", 50); err == nil || !strings.Contains(err.Error(), "failed to initialize") {
t.Fatalf("%v", err)
}
}
const infoOut = `Name : zsh
Version : 5.9.2-1
Depends On : pcre2 libcap gdbm
Optional Deps : grml-zsh-config: grml's zsh setup
zsh-doc: documentation [installed]
Required By : None
Install Reason : Explicitly installed
`
func TestInfoReadsListsAsListsAndFallsBackToTheRepositories(t *testing.T) {
p := ParseInfo(infoOut)
if len(p) != 1 {
t.Fatalf("%v", p)
}
if deps := p[0]["Depends On"].([]string); len(deps) != 3 || deps[2] != "gdbm" {
t.Fatalf("%v", p[0]["Depends On"])
}
if opt := p[0]["Optional Deps"].([]string); len(opt) != 2 || !strings.HasPrefix(opt[1], "zsh-doc") {
t.Fatalf("%v", p[0]["Optional Deps"])
}
if req := p[0]["Required By"].([]string); len(req) != 0 {
t.Fatalf("None is empty: %v", req)
}
var calls []call
m := machine(byLine(map[string]Ran{
"pacman -Qi -- zsh": {Status: 1, Stderr: "error: package 'zsh' was not found\n"},
"pacman -Si -- zsh": {Stdout: "Repository : extra\n" + infoOut},
}, &calls), 1000)
r, err := m.Info("zsh")
if err != nil || r["installed"] != false || r["package"].(map[string]any)["Repository"] != "extra" {
t.Fatalf("%v %v", r, err)
}
}
func TestInstalledSaysWhyAndWhatIsForeign(t *testing.T) {
m := machine(byLine(map[string]Ran{
"pacman -Q": {Stdout: "glibc 2.42-1\nyay 12.0-1\nzsh 5.9-1\n"},
"pacman -Qeq": {Stdout: "yay\nzsh\n"},
"pacman -Qmq": {Stdout: "yay\n"},
}, nil), 1000)
r, err := m.Installed("", "", false, 10)
if err != nil {
t.Fatal(err)
}
pk := r["packages"].([]Package)
if pk[0].Reason != "dependency" || pk[1].Reason != "explicit" || !pk[1].Foreign || pk[2].Foreign {
t.Fatalf("%+v", pk)
}
if tot := r["totals"].(map[string]int); tot["explicit"] != 2 || tot["dependency"] != 1 || tot["foreign"] != 1 {
t.Fatalf("%v", tot)
}
r, _ = m.Installed("", "", true, 10)
if r["count"] != 1 {
t.Fatalf("foreign only: %v", r)
}
r, _ = m.Installed("", "explicit", false, 1)
if r["count"] != 2 || r["truncated"] != true {
t.Fatalf("bounded: %v", r)
}
}
func TestOwnsAnswersNoOwnerAsAnAnswer(t *testing.T) {
m := machine(byLine(map[string]Ran{
"pacman -Qo -- /usr/bin/zsh": {Stdout: "/usr/bin/zsh is owned by zsh 5.9.2-1\n"},
"pacman -Qo -- /etc/hostname": {Status: 1, Stderr: "error: No package owns /etc/hostname\n"},
"pacman -Qo -- /nope": {Status: 1, Stderr: "error: failed to read file '/nope': No such file or directory\n"},
}, nil), 1000)
if r, err := m.Owns("/usr/bin/zsh"); err != nil || r["package"] != "zsh" || r["owned"] != true {
t.Fatalf("%v %v", r, err)
}
if r, err := m.Owns("/etc/hostname"); err != nil || r["owned"] != false {
t.Fatalf("%v %v", r, err)
}
if _, err := m.Owns("/nope"); err == nil {
t.Fatal("a path that is not there is an error")
}
if _, err := m.Owns("relative"); err == nil {
t.Fatal("a relative path was taken")
}
}
func TestUpdatesReadsCheckupdatesAndItsNothingToDo(t *testing.T) {
m := machine(byLine(map[string]Ran{"checkupdates": {Stdout: "linux 6.1-1 -> 6.2-1\nzsh 5.9-1 -> 5.9-2\n"}}, nil), 1000)
r, err := m.Updates()
if err != nil || r["count"] != 2 || r["updates"].([]Update)[0] != (Update{"linux", "6.1-1", "6.2-1"}) {
t.Fatalf("%v %v", r, err)
}
m = machine(byLine(map[string]Ran{"checkupdates": {Status: 2}}, nil), 1000)
if r, err := m.Updates(); err != nil || r["count"] != 0 {
t.Fatalf("%v %v", r, err)
}
m = machine(byLine(map[string]Ran{"checkupdates": {Status: 1, Stderr: "==> ERROR: Cannot fetch updates\n"}}, nil), 1000)
if _, err := m.Updates(); err == nil || !strings.Contains(err.Error(), "Cannot fetch updates") {
t.Fatalf("%v", err)
}
}
func lockless(m *Machine) *Machine {
m.ReadFile = func(p string) ([]byte, error) { return nil, errNoFile }
return m
}
func TestAnUpgradeRunsAsAUnitOfItsOwnThroughSudoAndBringsTheNews(t *testing.T) {
fetch = func(string) ([]byte, error) { return nil, errors.New("no network") }
var calls []call
m := lockless(machine(fake(func(c call) Ran { return Ran{} }, &calls), 1000))
r, err := m.Upgrade("", 60)
if err != nil {
t.Fatal(err)
}
unit := r["started"].(string)
if unit != "mesh-pacman-upgrade-1791115200" {
t.Fatalf("unit: %s", unit)
}
last := calls[len(calls)-1]
want := "sudo -n systemd-run --unit=" + unit
if !strings.HasPrefix(last.String(), want) || !strings.HasSuffix(last.String(), "--quiet pacman -Syu --noconfirm") || strings.Contains(last.String(), "--wait") {
t.Fatalf("started as: %s", last)
}
news := r["news"].(map[string]any)
if news["reachable"] != false || !strings.Contains(news["error"].(string), "no network") {
t.Fatalf("no network is an answer: %v", news)
}
}
func TestAnUpgradeIsRefusedWhileTheDatabaseIsLocked(t *testing.T) {
fetch = func(string) ([]byte, error) { return nil, errors.New("offline") }
var calls []call
m := machine(fake(func(c call) Ran { return Ran{} }, &calls), 1000)
m.ReadFile = func(p string) ([]byte, error) {
if p == DBLock {
return []byte{}, nil
}
return nil, errNoFile
}
if _, err := m.Upgrade("", 60); err == nil || !strings.Contains(err.Error(), "another pacman holds") {
t.Fatalf("%v", err)
}
for _, c := range calls {
if c.name == "sudo" {
t.Fatal("started while locked")
}
}
}
func TestAnUpgradesUnitIsReadBack(t *testing.T) {
unit := "mesh-pacman-upgrade-1791115200"
m := machine(byLine(map[string]Ran{
"systemctl show " + unit + " --no-pager --property=LoadState --property=ActiveState --property=SubState --property=Result --property=ExecMainStatus": {Stdout: "LoadState=loaded\nActiveState=failed\nSubState=failed\nResult=exit-code\nExecMainStatus=1\n"},
"sudo -n journalctl --no-pager -o cat -n 60 -u " + unit: {Stdout: "error: failed to commit transaction (conflicting files)\n"},
}, nil), 1000)
r, err := m.Upgrade(unit, 60)
if err != nil || r["finished"] != true || r["succeeded"] != false || r["exit_status"] != "1" || len(r["log"].([]string)) != 1 {
t.Fatalf("%v %v", r, err)
}
if _, err := m.Upgrade("sshd.service", 60); err == nil {
t.Fatal("a unit the tools did not start was read")
}
}
func orphanMachine(calls *[]call) *Machine {
return lockless(machine(fake(func(c call) Ran {
switch {
case c.String() == "pacman -Qdt":
return Ran{Stdout: "argon2 20190702-6\nclang21 21.1.8-1\n"}
case c.name == "sudo" && c.args[1] == "systemd-run":
return Ran{}
case c.name == "sudo" && c.args[1] == "journalctl":
return Ran{Stdout: "removing argon2...\n"}
}
return Ran{Status: 99}
}, calls), 1000))
}
func TestRemovingOrphansTakesOnlyOrphansNamedOrAll(t *testing.T) {
var calls []call
m := orphanMachine(&calls)
if _, err := m.RemoveOrphans(nil, false); err == nil || !strings.Contains(err.Error(), "name the orphans") {
t.Fatalf("nothing named: %v", err)
}
if _, err := m.RemoveOrphans([]string{"glibc"}, false); err == nil || !strings.Contains(err.Error(), "glibc is not an orphan") {
t.Fatalf("not an orphan: %v", err)
}
r, err := m.RemoveOrphans([]string{"argon2"}, false)
if err != nil || strings.Join(r["removed"].([]string), ",") != "argon2" {
t.Fatalf("%v %v", r, err)
}
var run string
for _, c := range calls {
if c.name == "sudo" && c.args[1] == "systemd-run" {
run = c.String()
}
}
if !strings.Contains(run, "--wait pacman -Rs --noconfirm -- argon2") {
t.Fatalf("ran: %s", run)
}
r, _ = m.RemoveOrphans(nil, true)
if len(r["removed"].([]string)) != 2 {
t.Fatalf("all: %v", r)
}
}
func TestCacheSaysWhatCleaningWouldFreeAndCleansThroughSudo(t *testing.T) {
var calls []call
m := machine(fake(func(c call) Ran {
switch c.String() {
case "find /var/cache/pacman/pkg -mindepth 1 -maxdepth 1 -printf %y %s %f\n":
return Ran{Status: 1, Stdout: "f 1000 zsh-5.9-1-x86_64.pkg.tar.zst\nf 10 zsh-5.9-1-x86_64.pkg.tar.zst.sig\nd 4096 download-abc\n", Stderr: "find: permission denied\n"}
case "paccache -d -k 3":
return Ran{Stdout: "\n==> finished dry run: 12 candidates (disk space saved: 1.5 GiB)\n"}
case "sudo -n paccache -r -k 3":
return Ran{Stdout: "==> finished: 12 packages removed (disk space saved: 1.5 GiB)\n"}
}
if c.name == "systemctl" {
return Ran{Stdout: "ActiveState=active\nUnitFileState=enabled\n"}
}
return Ran{Status: 99}
}, &calls), 1000)
c, err := m.Cache(3, false, false)
if err != nil || c.Files != 1 || c.Bytes != 1010 || c.LeftDownloads != 1 || c.Candidates != 12 || c.Frees != "1.5 GiB" || c.Cleaned {
t.Fatalf("%+v %v", c, err)
}
c, err = m.Cache(3, false, true)
if err != nil || !c.Cleaned || c.Candidates != 12 || c.Timer["UnitFileState"] != "enabled" {
t.Fatalf("%+v %v", c, err)
}
}
const pacmanLog = `[2026-09-24T17:47:36+0200] [PACMAN] starting full system upgrade
[2026-09-24T17:48:00+0200] [ALPM] upgraded linux (6.1-1 -> 6.2-1)
[2026-09-24T17:48:01+0200] [ALPM] installed zsh (5.9-1)
[2026-10-02T09:00:00+0200] [ALPM] removed ntp (4.2.8-1)
[2026-10-02T09:00:00+0200] [ALPM-SCRIPTLET] some words
[2022-01-01 10:00] [ALPM] installed old (1-1)
`
func TestHistoryReadsTheLogSinceADayAndTheLastFullUpgrade(t *testing.T) {
m := machine(nil, 1000)
m.ReadFile = func(p string) ([]byte, error) { return []byte(pacmanLog), nil }
r, err := m.History("2026-09-01", "", "", 10)
if err != nil {
t.Fatal(err)
}
ev := r["events"].([]Event)
if len(ev) != 3 || ev[0].From != "6.1-1" || ev[0].Version != "6.2-1" || ev[2].Action != "removed" {
t.Fatalf("%+v", ev)
}
if r["last_full_upgrade"] != "2026-09-24T17:47:36+02:00" {
t.Fatalf("%v", r["last_full_upgrade"])
}
r, _ = m.History("2026-09-01", "removed", "", 10)
if r["count"] != 1 {
t.Fatalf("%v", r)
}
r, _ = m.History("2026-09-01", "", "", 1)
if r["truncated"] != true || r["events"].([]Event)[0].Package != "ntp" {
t.Fatalf("the newest are kept: %v", r)
}
if _, err := m.History("yesterday", "", "", 1); err == nil {
t.Fatal("not a day")
}
if _, err := m.History("", "exploded", "", 1); err == nil {
t.Fatal("not an action")
}
}
const feed = `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"><channel><title>Arch Linux: Recent news updates</title>
<item><title>Manual intervention needed</title><link>https://example.org/news/a/</link><description>&lt;p&gt;Do this &lt;b&gt;first&lt;/b&gt;.&lt;/p&gt;</description><pubDate>Tue, 22 Sep 2026 09:09:27 +0000</pubDate></item>
<item><title>Old news</title><link>https://example.org/news/b/</link><description>old</description><pubDate>Mon, 01 Jun 2026 09:00:00 +0000</pubDate></item>
</channel></rss>`
func TestNewsSinceTheLastUpgrade(t *testing.T) {
items, err := ParseNews([]byte(feed), time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
if err != nil || len(items) != 1 || items[0].Title != "Manual intervention needed" || items[0].Summary != "Do this first ." {
t.Fatalf("%+v %v", items, err)
}
fetch = func(string) ([]byte, error) { return []byte(feed), nil }
m := machine(nil, 1000)
m.ReadFile = func(p string) ([]byte, error) { return []byte(pacmanLog), nil }
n := m.News("")
if n["reachable"] != true || n["since_last_full_upgrade"] != true || len(n["items"].([]NewsItem)) != 0 {
t.Fatalf("after the last upgrade on the 24th, the post of the 22nd is old: %v", n)
}
}
const mirrorlistReflector = `################################################################################
################# Arch Linux mirrorlist generated by Reflector #################
################################################################################
# With: reflector @/etc/xdg/reflector/reflector.conf
# When: 2024-06-12 21:26:34 UTC
Server = https://mirror.example.org/archlinux/$repo/os/$arch
Server = https://mirror2.example.org/$repo/os/$arch
#Server = https://old.example.org/$repo/os/$arch
`
func TestMirrorsReadTheListAndRefreshWithoutWaiting(t *testing.T) {
var calls []call
m := machine(fake(func(c call) Ran {
if c.name == "systemctl" && c.args[0] == "show" {
return Ran{Stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\n"}
}
return Ran{}
}, &calls), 1000)
m.ReadFile = func(p string) ([]byte, error) {
switch p {
case Mirrorlist:
return []byte(mirrorlistReflector), nil
case ReflectorConf:
return []byte("# comment\n--save /etc/pacman.d/mirrorlist\n--sort rate\n"), nil
}
return nil, errNoFile
}
r, err := m.MirrorList(true)
if err != nil || len(r.Servers) != 2 || r.Commented != 1 || r.GeneratedBy != "reflector" || r.When != "2024-06-12 21:26:34 UTC" || len(r.Reflector) != 2 || !r.Refreshing {
t.Fatalf("%+v %v", r, err)
}
if calls[len(calls)-1].String() != "sudo -n systemctl start --no-block reflector.service" {
t.Fatalf("%v", calls[len(calls)-1])
}
}
func TestConfigIsReadAsPacmanConfResolvesIt(t *testing.T) {
c := ParseConf("[options]\nHoldPkg = pacman\nHoldPkg = glibc\nCheckSpace\nParallelDownloads = 5\n[core]\nUsage = All\nServer = https://a/core\nServer = https://b/core\n[extra]\nServer = https://a/extra\n")
if len(c.Options["HoldPkg"]) != 2 || c.Options["ParallelDownloads"][0] != "5" || len(c.Repositories) != 2 || c.Repositories[0].Servers != 2 || c.Repositories[0].FirstServer != "https://a/core" {
t.Fatalf("%+v", c)
}
}