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).
178 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|