Files
mesh-catalog/modules/pacman/cmd/pacman-tools/pacman_test.go
T

433 lines
18 KiB
Go

package main
import (
"context"
"fmt"
"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 TestReadInstalledSaysWhyAndWhichRepositoryOrForeign(t *testing.T) {
m := machine(byLine(map[string]Ran{
"pacman -Q": {Stdout: "zsh 5.9-1\nglibc 2.42-1\nyay 12.0-1\n"},
"pacman -Qeq": {Stdout: "yay\nzsh\n"},
"pacman -Sl": {Stdout: "core glibc 2.42-1 [installed]\nextra zsh 5.9-1 [installed]\nextra-testing zsh 5.9-2\n"},
}, nil), 1000)
pk, skipped, err := m.ReadInstalled()
if err != nil || skipped != 0 {
t.Fatal(err, skipped)
}
want := []InstalledPackage{{"glibc", "2.42-1", false, "core"}, {"yay", "12.0-1", true, RepoForeign}, {"zsh", "5.9-1", true, "extra"}}
if !samePackages(pk, want) {
t.Fatalf("%+v", pk)
}
}
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 TestUpdateListReadsCheckupdatesAndItsNothingToDo(t *testing.T) {
m := machine(byLine(map[string]Ran{"checkupdates": {Stdout: "zsh 5.9-1 -> 5.9-2\nlinux 6.1-1 -> 6.2-1\n"}}, nil), 1000)
r, err := m.UpdateList()
if err != nil || len(r) != 2 || r[0] != (VersionChange{"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.UpdateList(); err != nil || r == nil || len(r) != 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.UpdateList(); 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 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.UpgradeStatus(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.UpgradeStatus("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
`
// reflectorInfo is reflector's --info as it answered on 2026-11-01 (reflector 2023, its fields cut to those
// read and one more), the ranked order kept: two mirrors at the newest last sync, one behind, one with none.
const reflectorInfo = `https://nl.mirror.example/archlinux/$repo/os/$arch
active : True
country : Netherlands
last_sync : 2026-11-01 14:32:01 UTC
https://be.mirror.example/archlinux/$repo/os/$arch
active : True
last_sync : 2026-11-01 14:01:45 UTC
https://de.mirror.example/$repo/os/$arch
last_sync : 2026-11-01 14:32:01 UTC
https://fr.mirror.example/archlinux/$repo/os/$arch
last_sync : None
`
// rankedAt is after every last sync in reflectorInfo: reflector ran then.
var rankedAt = time.Date(2026, 11, 1, 15, 0, 0, 0, time.UTC)
// ADR 0586 §1, ADR 0585 §4: the ranking keeps the mirrors at the newest sync and a tail of the next freshest, in reflector's order, with the module's options
// and never its --save; a ranking that fails or keeps none is no list. pacman_mirrors answers the shared list, its
// ranker and age and whether this node's file matches it (the file as found when not); with refresh it asks this node's
// keeper to rank again, refused while a round runs, naming it.
func TestTheMeshsMirrorListIsRankedFreshAndAnsweredWithThisNodesFile(t *testing.T) {
conf := "# comment\n--save /etc/pacman.d/mirrorlist\n--country Belgium,Netherlands\n--sort rate\n"
if o := strings.Join(ReflectorOptions(conf), " "); o != "--country Belgium,Netherlands --sort rate" {
t.Errorf("the options are %q", o)
}
var asked []string
answer := Ran{Stdout: reflectorInfo}
saved := reflectorRun
reflectorRun = func(_ context.Context, args ...string) Ran { asked = args; return answer }
defer func() { reflectorRun = saved }()
l, err := RankMirrors([]byte(conf), "node-a", rankedAt, "2026-W43")
if err != nil || strings.Join(l.Servers, " ") != "https://nl.mirror.example/archlinux/$repo/os/$arch https://de.mirror.example/$repo/os/$arch https://be.mirror.example/archlinux/$repo/os/$arch" || l.Freshest != 2 ||
!l.Newest.Equal(time.Date(2026, 11, 1, 14, 32, 1, 0, time.UTC)) || l.RankedBy != "node-a" || l.Round != "2026-W43" ||
strings.Join(asked, " ") != "--country Belgium,Netherlands --sort rate --info" {
t.Fatalf("ranked %+v (%v), reflector asked %v", l, err, asked)
}
for _, a := range []Ran{{Status: 1, Stderr: "error: no mirrors"}, {Stdout: "https://x.example/$repo/os/$arch\nlast_sync : None\n"}} {
answer = a
if _, err := RankMirrors([]byte(conf), "node-a", rankedAt, ""); err == nil {
t.Errorf("a ranking from %+v is a list", a)
}
}
checkFreshest(t)
if checkServers([]string{"https://ok.example/$repo/os/$arch\nServer = https://evil.example/$repo/os/$arch"}) == nil {
t.Error("a server carrying a second line is taken")
}
states := &fakeStates{}
states.store(StateMirrors).Put(MirrorsKey, l)
file := RenderMirrorlist(l.Servers)
m := machine(func(context.Context, string, ...string) Ran {
return Ran{Stdout: "LoadState=loaded\nUnitFileState=disabled\nActiveState=inactive\n"}
}, 1000)
m.Node, m.States = "node-a", readerOf(states)
m.Now = func() time.Time { return rankedAt.Add(90 * time.Minute) }
m.ReadFile = func(string) ([]byte, error) { return file, nil }
v, err := m.MirrorsView(false)
if err != nil || v["file_matches"] != true || v["ranked_by"] != "node-a" || v["age"] != "1h30m0s" || v["file"] != nil ||
v["reflector_timer"].(map[string]string)["UnitFileState"] != "disabled" {
t.Fatalf("the view is %v (%v)", v, err)
}
file = []byte(mirrorlistReflector)
if v, _ := m.MirrorsView(false); v["file_matches"] != false || v["file"].(Mirrors).GeneratedBy != "reflector" {
t.Errorf("a file other than the list is %v", v)
}
rounds, runs, asks := newBucket(), newBucket(), newBucket()
savedState, savedNode := upgradeState, thisNode
upgradeState = func(name string) Bucket {
return map[string]Bucket{StateRounds: rounds, StateRuns: runs, StateAsks: asks}[name]
}
thisNode = func() string { return "node-a" }
defer func() { upgradeState, thisNode = savedState, savedNode }()
rounds.Put("2026-W43", Round{Round: "2026-W43", Opened: t0, OpenedBy: "node-a", Order: []string{"node-a", "node-b"}})
if _, err := m.MirrorsView(true); err == nil || !strings.Contains(err.Error(), "round 2026-W43 runs") {
t.Errorf("a refresh during a round: %v", err)
}
if keys, _ := asks.Keys(); len(keys) != 0 {
t.Errorf("a refused refresh asked %v", keys)
}
rounds.Delete("2026-W43")
v, err = m.MirrorsView(true)
if keys, _ := asks.Keys(); err != nil || len(keys) != 1 || !strings.HasPrefix(keys[0], "node-a.") || v["asked"] == nil {
t.Errorf("a refresh asked %v (%v): %v", keys, err, v)
}
}
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)
}
}
// ADR 0586 §1: the freshest first, then up to four of the next freshest, newest first; an unknown last sync is left out,
// whether reflector wrote None or the moment it ran (gmtime(None)).
func checkFreshest(t *testing.T) {
t.Helper()
at := func(m int) time.Time { return time.Date(2026, 11, 1, 14, m, 0, 0, time.UTC) }
mk := func(syncs ...int) []rankedMirror {
var r []rankedMirror
for i, m := range syncs {
mm := rankedMirror{URL: fmt.Sprintf("m%d", i)}
if m >= 0 {
mm.LastSync = at(m)
}
r = append(r, mm)
}
return r
}
for _, c := range []struct {
name string
ranked []rankedMirror
servers string
fresh int
}{
{"all fresh", mk(30, 30, 30), "m0 m1 m2", 3},
{"one fresh and a tail of four", mk(10, 30, 20, 25, 5, 1), "m1 m3 m2 m0 m4", 1},
{"more than four candidates", mk(30, 29, 28, 27, 26, 25, 24), "m0 m1 m2 m3 m4", 1},
{"two fresh and the tail", mk(30, 20, 30, 10, 11, 12, 13), "m0 m2 m1 m6 m5 m4", 2},
{"unknown last sync left out", mk(30, -1, 20, -1), "m0 m2", 1},
{"none known", mk(-1, -1), "", 0},
} {
got, _, fresh := Freshest(c.ranked)
if strings.Join(got, " ") != c.servers || fresh != c.fresh {
t.Errorf("%s: kept %v, %d freshest; want %q, %d", c.name, got, fresh, c.servers, c.fresh)
}
}
// reflector prints an unknown last sync as the moment it ran: at or after the start of the run is no real sync.
start := time.Date(2026, 11, 1, 15, 0, 0, 500, time.UTC)
r := []rankedMirror{{"a", at(30)}, {"b", start.Truncate(time.Second)}, {"c", start.Add(time.Second)}}
withoutUnknownSync(r, start)
if r[0].LastSync.IsZero() || !r[1].LastSync.IsZero() || !r[2].LastSync.IsZero() {
t.Errorf("unknown syncs not detected: %+v", r)
}
}