Files
mesh-catalog/modules/systemd/cmd/systemd-tools/unlink_test.go
T
jochen 87c9d9f7e5 Serve unlink-dangling, so a leftover enable link goes through the seat
systemctl disable leaves a link whose unit file is gone, and the manager
keeps asking for the unit. The verb removes only such links in the
account's or the machine's own configuration, on the person's word, and
records each with its reason before removing it (hq issue 332).
2026-10-08 17:53:53 +02:00

178 lines
7.1 KiB
Go

package main
// Removing a dangling enable link on the person's word (novox/hq issue 332), over the fake listing of
// wanted_test.go: what is removed and what is left, the record kept before each removal, and who removes.
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// leftovers is a machine with one removable dangling link and every kind that must be left.
func leftovers() tree {
user := home + "/.config/systemd/user"
return tree{
user: {{Name: "default.target.wants", Dir: true}},
user + "/default.target.wants": {
{Name: "greenclip.service", Link: "/usr/lib/systemd/user/greenclip.service", Dangles: true}, // removed
{Name: "clip.service", Link: "/usr/lib/systemd/user/greenclip.service", Dangles: true}, // named for another unit
},
"/etc/systemd/user": {{Name: "graphical-session.target.wants", Dir: true}},
"/etc/systemd/user/graphical-session.target.wants": {
{Name: "greenclip.service", Link: "/usr/lib/systemd/user/greenclip.service", Dangles: true}, // removed, as root
},
"/usr/lib/systemd/user": {{Name: "default.target.wants", Dir: true}},
"/usr/lib/systemd/user/default.target.wants": {{Name: "greenclip.service", Link: "../greenclip.service", Dangles: true}}, // a package's
}
}
func unlinker(t *testing.T, fsys tree, calls *[]call, removed *[]string) *Manager {
m := withTree(operator(fake(func(call) Ran { return Ran{} }, calls)), fsys)
m.State = t.TempDir()
m.Now = func() time.Time { return time.Date(2026, 10, 8, 17, 30, 0, 0, time.UTC) }
m.Remove = func(p string) error { *removed = append(*removed, p); return nil }
return m
}
func records(t *testing.T, m *Manager) []Unlinked {
f, err := os.Open(filepath.Join(m.State, UnlinkedRecord))
if err != nil {
t.Fatal(err)
}
defer f.Close()
var out []Unlinked
s := bufio.NewScanner(f)
for s.Scan() {
var r Unlinked
if err := json.Unmarshal(s.Bytes(), &r); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
return out
}
func TestUnlinkDanglingRemovesOnlyTheDanglingLinksNamedForTheUnitInOwnConfiguration(t *testing.T) {
var calls []call
var removed []string
m := unlinker(t, leftovers(), &calls, &removed)
answer, err := m.UnlinkDangling(User, "greenclip.service", "greenclip was replaced by clipmenu")
if err != nil {
t.Fatal(err)
}
own := home + "/.config/systemd/user/default.target.wants/greenclip.service"
if !reflect.DeepEqual(removed, []string{own}) {
t.Fatalf("removed by the account: %v", removed)
}
// The machine's own, outside the home, as root through sudo -n unlink; then the user manager reloaded.
var sudo, reload bool
for _, c := range calls {
if c.cmd == "sudo" && reflect.DeepEqual(c.args, []string{"-n", "unlink", "/etc/systemd/user/graphical-session.target.wants/greenclip.service"}) {
sudo = true
}
if c.cmd == "systemctl" && reflect.DeepEqual(c.args, []string{"--user", "daemon-reload"}) {
reload = sudo // after the removals
}
}
if !sudo || !reload {
t.Fatalf("%+v", calls)
}
got := records(t, m)
if len(got) != 2 || got[0].Path != "/etc/systemd/user/graphical-session.target.wants/greenclip.service" || got[1].Path != own ||
got[1].Target != "/usr/lib/systemd/user/greenclip.service" || got[1].Of != "default.target" ||
got[1].Why != "greenclip was replaced by clipmenu" || got[1].At != "2026-10-08T17:30:00Z" || got[1].Scope != "user" {
t.Fatalf("%+v", got)
}
left := answer["left"].([]map[string]string)
why := map[string]string{}
for _, l := range left {
why[l["path"]] = l["why_left"]
}
if !strings.Contains(why[home+"/.config/systemd/user/default.target.wants/clip.service"], "named for another unit") ||
!strings.Contains(why["/usr/lib/systemd/user/default.target.wants/greenclip.service"], "a package's") {
t.Fatalf("%v", why)
}
}
func TestUnlinkDanglingLeavesALinkWhoseTargetExistsOrThatChanged(t *testing.T) {
user := home + "/.config/systemd/user"
fsys := tree{
user: {{Name: "default.target.wants", Dir: true}},
user + "/default.target.wants": {{Name: "x.service", Link: "/usr/lib/systemd/user/x.service"}},
}
var removed []string
m := unlinker(t, fsys, nil, &removed)
answer, err := m.UnlinkDangling(User, "x.service", "test")
if err != nil || len(removed) != 0 || !strings.Contains(answer["left"].([]map[string]string)[0]["why_left"], "`disable`") {
t.Fatalf("%v %v %v", answer, err, removed)
}
// Found dangling, then pointed elsewhere before it is removed.
fsys[user+"/default.target.wants"] = []Entry{{Name: "x.service", Link: "/gone/x.service", Dangles: true}}
m = unlinker(t, fsys, nil, &removed)
m.Lstat = func(string) (Entry, error) {
return Entry{Name: "x.service", Link: "/usr/lib/systemd/user/x.service"}, nil
}
answer, err = m.UnlinkDangling(User, "x.service", "test")
if err != nil || len(removed) != 0 || answer["left"].([]map[string]string)[0]["why_left"] != "it changed since it was read" {
t.Fatalf("%v %v %v", answer, err, removed)
}
if _, err := os.Stat(filepath.Join(m.State, UnlinkedRecord)); !os.IsNotExist(err) {
t.Fatal("a record was kept of a link that was not removed")
}
}
func TestUnlinkDanglingNeedsAReasonAndAPlaceForTheRecord(t *testing.T) {
var removed []string
m := unlinker(t, leftovers(), nil, &removed)
if _, err := m.UnlinkDangling(User, "greenclip.service", " "); err == nil || !strings.Contains(err.Error(), "why is required") {
t.Fatalf("%v", err)
}
m.State = ""
if _, err := m.UnlinkDangling(User, "greenclip.service", "x"); err == nil {
t.Fatal("removed with nowhere to record")
}
// A record that cannot be written: nothing is removed.
file := filepath.Join(t.TempDir(), "a-file")
if err := os.WriteFile(file, nil, 0o600); err != nil {
t.Fatal(err)
}
m.State = file
if _, err := m.UnlinkDangling(User, "greenclip.service", "x"); err == nil || !strings.Contains(err.Error(), "not removed") || len(removed) != 0 {
t.Fatalf("%v %v", err, removed)
}
}
func TestUnlinkDanglingInTheSystemScopeGoesThroughSudo(t *testing.T) {
fsys := tree{
"/etc/systemd/system": {{Name: "multi-user.target.wants", Dir: true}},
"/etc/systemd/system/multi-user.target.wants": {{Name: "old.service", Link: "/usr/lib/systemd/system/old.service", Dangles: true}},
"/run/systemd/generator": {{Name: "local-fs.target.wants", Dir: true}},
"/run/systemd/generator/local-fs.target.wants": {{Name: "old.service", Link: "/gone", Dangles: true}},
}
var calls []call
var removed []string
m := unlinker(t, fsys, &calls, &removed)
answer, err := m.UnlinkDangling(System, "old.service", "the package is gone")
if err != nil {
t.Fatal(err)
}
if len(removed) != 0 {
t.Fatalf("removed without sudo: %v", removed)
}
if got := append([]string{calls[0].cmd}, calls[0].args...); !reflect.DeepEqual(got, []string{"sudo", "-n", "unlink", "/etc/systemd/system/multi-user.target.wants/old.service"}) {
t.Fatalf("%v", got)
}
if got := append([]string{calls[1].cmd}, calls[1].args...); !reflect.DeepEqual(got, []string{"sudo", "-n", "systemctl", "daemon-reload"}) {
t.Fatalf("%v", got)
}
if len(answer["removed"].([]Unlinked)) != 1 || len(answer["left"].([]map[string]string)) != 1 {
t.Fatalf("%v", answer)
}
}