Download
537 lines 19.6 KiBGoModified 2026-10-23 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
package setup_test

import (
	"archive/tar"
	"bytes"
	"errors"
	"io"
	"io/fs"
	"net/http"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"testing"
	"testing/fstest"

	"code.bruce-hill.com/repo-renderer/internal/config"
	"code.bruce-hill.com/repo-renderer/internal/setup"
)

// fakeOS is a system under a temporary root, with the commands the
// checks run and httpd's answers standing in for the real ones.
type fakeOS struct {
	t      *testing.T
	root   string
	owners map[string]string // path → "owner:group"; missing is root:wheel
	// httpd: whether it is running, whether httpd -n rejects its
	// configuration, and what status it answers the site's page with.
	running, rejects bool
	status           int
	checkFails       bool // repo-render check fails
	env              map[string]string
}

var share = fstest.MapFS{
	"hooks/post-receive":        {Data: []byte("#!/bin/sh\nexec repo-render enqueue\n")},
	"lexers/tomo.xml":           {Data: []byte("<lexer/>\n")},
	"examples/repo-render.yaml": {Data: []byte("host: code.example.com\n")},
}

func (f *fakeOS) path(p string) string { return filepath.Join(f.root, p) }

func (f *fakeOS) write(p, data string, mode fs.FileMode, owner string) {
	f.t.Helper()
	if err := os.MkdirAll(filepath.Dir(f.path(p)), 0o755); err != nil {
		f.t.Fatal(err)
	}
	if err := os.WriteFile(f.path(p), []byte(data), mode); err != nil {
		f.t.Fatal(err)
	}
	if err := os.Chmod(f.path(p), mode); err != nil {
		f.t.Fatal(err)
	}
	if owner != "" {
		f.owners[p] = owner
	}
}

func (f *fakeOS) mkdir(p string, mode fs.FileMode, owner string) {
	f.t.Helper()
	if err := os.MkdirAll(f.path(p), 0o755); err != nil {
		f.t.Fatal(err)
	}
	if err := os.Chmod(f.path(p), mode); err != nil {
		f.t.Fatal(err)
	}
	if owner != "" {
		f.owners[p] = owner
	}
}

func (f *fakeOS) run(e *setup.Env, c setup.Cmd) ([]byte, error) {
	switch {
	case c.Argv[0] == "httpd" && f.rejects:
		return []byte("/etc/httpd.conf:3: syntax error"), errors.New("httpd -n failed")
	case c.Argv[0] == "rcctl" && !f.running:
		return []byte("httpd(failed)"), errors.New("not running")
	case c.Argv[0] == setup.BinPath && f.checkFails:
		return []byte("lexer \"tomo\" resolves to plain text"), errors.New("check failed")
	}
	return nil, nil
}

func (f *fakeOS) fetch(url, host string) (*http.Response, error) {
	if url != "http://127.0.0.1:8080/" || host != "code.example.com" {
		f.t.Errorf("fetched %s for %s", url, host)
	}
	h := http.Header{}
	if f.status == 200 {
		h.Set("Content-Security-Policy", "default-src 'none'")
	}
	return &http.Response{StatusCode: f.status, Status: http.StatusText(f.status), Header: h, Body: io.NopCloser(strings.NewReader(""))}, nil
}

// fresh is an OpenBSD install with nothing of repo-render's on it.
func fresh(t *testing.T) (*fakeOS, *setup.Env) {
	f := &fakeOS{t: t, root: t.TempDir(), owners: map[string]string{}, status: 200, env: map[string]string{}}
	f.write("/etc/passwd", "root:*:0:0::/root:/bin/ksh\nbruce:*:1000:1000::/home/bruce:/bin/ksh\n", 0o644, "")
	f.write("/etc/group", "wheel:*:0:root\nbin:*:7:\n", 0o644, "")
	f.write("/etc/shells", "/bin/sh\n/bin/ksh\n", 0o644, "")
	f.write("/etc/installurl", "https://cdn.openbsd.org/pub/OpenBSD\n", 0o644, "")
	f.write("/root/.ssh/authorized_keys", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA root@laptop\n", 0o600, "")
	for _, d := range []string{"/var/www/htdocs", "/var/db", "/var/spool", "/usr/local/bin", "/usr/local/share"} {
		f.mkdir(d, 0o755, "")
	}
	e := &setup.Env{Root: f.root, Run: f.run, Fetch: f.fetch, AsRoot: true,
		Getenv: func(k string) string { return f.env[k] },
		OwnerOf: func(p string) (string, string, error) {
			if _, err := os.Lstat(f.path(p)); err != nil {
				return "", "", err
			}
			o, ok := f.owners[p]
			if !ok {
				o = "root:wheel"
			}
			owner, group, _ := strings.Cut(o, ":")
			return owner, group, nil
		}}
	return f, e
}

// ready is a system with everything in place.
func ready(t *testing.T) (*fakeOS, *setup.Env) {
	f, e := fresh(t)
	f.write("/etc/passwd", "root:*:0:0::/root:/bin/ksh\ngit:*:1001:1001::/var/git:/usr/local/bin/git-shell\n", 0o644, "")
	f.write("/etc/group", "wheel:*:0:root\nbin:*:7:\ngit:*:1001:\n", 0o644, "")
	f.write(setup.GitPath, "git", 0o755, "")
	f.write(setup.GitShell, "git-shell", 0o755, "")
	f.write("/etc/shells", "/bin/sh\n/bin/ksh\n/usr/local/bin/git-shell\n", 0o644, "")
	f.write(config.DefaultPath, "host: code.example.com\n", 0o644, "")
	f.mkdir("/var/git", 0o755, "git:git")
	for _, d := range []string{"/var/www/htdocs/code.example.com", "/var/db/repo-render", "/var/spool/repo-render"} {
		f.mkdir(d, 0o755, "git:git")
	}
	f.mkdir("/var/git/.ssh", 0o700, "git:git")
	f.write("/var/git/.ssh/authorized_keys", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA root@laptop\n", 0o600, "git:git")
	f.write(setup.BinPath, "repo-render", 0o755, "")
	_ = fs.WalkDir(share, ".", func(name string, d fs.DirEntry, err error) error {
		if d.IsDir() {
			return err
		}
		dest, mode := filepath.Join(setup.ShareDir, name), fs.FileMode(0o644)
		switch {
		case strings.HasPrefix(name, "hooks/"):
			mode = 0o755
		case strings.HasPrefix(name, "examples/"):
			dest = filepath.Join(setup.ShareDir, filepath.Base(name))
		}
		f.write(dest, string(share[name].Data), mode, "")
		return nil
	})
	f.write(setup.HTTPDPath, "prefork 3\n"+setup.IncludeLine+"\n", 0o644, "")
	cfg, err := config.Parse([]byte("host: code.example.com\n"))
	if err != nil {
		t.Fatal(err)
	}
	conf, err := setup.SiteConf(e, cfg)
	if err != nil {
		t.Fatal(err)
	}
	f.write(setup.IncludePath, string(conf), 0o644, "")
	f.write(setup.RCConfLocal, "httpd_flags=\n", 0o644, "")
	f.write("/var/www/htdocs/code.example.com/index.html", "<!doctype html>", 0o644, "git:git")
	f.running = true
	return f, e
}

func options(f *fakeOS) setup.Options {
	return setup.Options{ConfigPath: config.DefaultPath, Self: f.path(setup.BinPath), Share: share}
}

// byTitle finds a check.
func byTitle(t *testing.T, checks []*setup.Check, title string) *setup.Check {
	t.Helper()
	for _, c := range checks {
		if c.Title == title {
			return c
		}
	}
	t.Fatalf("no check %q", title)
	return nil
}

// failing lists the checks that are not OK, with their fixes.
func failing(checks []*setup.Check) []string {
	var out []string
	for _, c := range checks {
		if c.Status != setup.OK {
			out = append(out, c.Title+": "+c.Detail+": "+strings.Join(c.Fix, "; "))
		}
	}
	return out
}

func TestReadySystemPasses(t *testing.T) {
	f, e := ready(t)
	checks := setup.Run(e, options(f))
	if left := failing(checks); len(left) > 0 {
		t.Errorf("a ready system fails:\n%s", strings.Join(left, "\n"))
	}
	var out bytes.Buffer
	if n := setup.Report(&out, checks, false); n != 0 {
		t.Errorf("Report counts %d not done:\n%s", n, out.String())
	}
}

func TestFreshSystem(t *testing.T) {
	f, e := fresh(t)
	f.env["DOAS_USER"] = "bruce" // who has no keys: root's are taken
	f.write("/home/bruce/repo-render", "repo-render", 0o755, "")
	o := options(f)
	o.Host, o.Self = "code.example.com", f.path("/home/bruce/repo-render")
	checks := setup.Run(e, o)
	want := map[string][]string{
		"git":                          {"pkg_add git"},
		"git account":                  {"useradd -d /var/git -g =uid -s /usr/local/bin/git-shell git"},
		"configuration":                {"install -d -m 755 /etc/repo-render", "echo 'host: code.example.com' > /etc/repo-render/repo-render.yaml"},
		"repositories directory":       {"install -d -o git -g git -m 755 /var/git"},
		"site directory":               {"install -d -o git -m 755 /var/www/htdocs/code.example.com"},
		"SSH keys that may push":       {"install -d -o git -g git -m 700 /var/git/.ssh", "install -o git -g git -m 600 /dev/null /var/git/.ssh/authorized_keys", "cat /root/.ssh/authorized_keys >> /var/git/.ssh/authorized_keys"},
		"repo-render":                  {"install -o root -g bin -m 755 " + o.Self + " " + setup.BinPath},
		"httpd.conf includes the site": {"echo 'include \"/etc/repo-render/httpd.conf\"' >> /etc/httpd.conf"},
		"httpd starts at boot":         {"rcctl enable httpd"},
		// It reads the config the configuration's fix writes.
		"the site's httpd configuration": {o.Self + " httpd-conf > " + setup.IncludePath},
	}
	for title, fix := range want {
		c := byTitle(t, checks, title)
		if c.Status != setup.Fix || !slices.Equal(c.Fix, fix) {
			t.Errorf("%s: %v %q, want ✗ %q (%s)", title, c.Status, c.Fix, fix, c.Detail)
		}
	}
	for _, title := range []string{"git-shell is a login shell", "httpd accepts its configuration", "the site is rendered", "httpd serves the site"} {
		if c := byTitle(t, checks, title); c.Status != setup.Later {
			t.Errorf("%s is %v, want waiting on earlier checks", title, c.Status)
		}
	}
}

func TestNoHostGivenUsesPlaceholder(t *testing.T) {
	f, e := fresh(t)
	checks := setup.Run(e, options(f))
	c := byTitle(t, checks, "configuration")
	if !slices.Contains(c.Fix, "echo 'host: HOST' > /etc/repo-render/repo-render.yaml") || !strings.Contains(c.Detail, "--host") {
		t.Errorf("config fix %q (%s)", c.Fix, c.Detail)
	}
	// With no host, the config written may not be the one in the end,
	// and the site's directory has no name yet.
	for _, title := range []string{"the site's httpd configuration", "site directory"} {
		if c := byTitle(t, checks, title); c.Status != setup.Later {
			t.Errorf("%s with no host yet is %v %q, want waiting", title, c.Status, c.Fix)
		}
	}
}

func TestSiteOwnedBySomeoneElse(t *testing.T) {
	f, e := ready(t)
	f.mkdir("/var/www/htdocs/code.example.com", 0o775, "bruce:www-data")
	checks := setup.Run(e, options(f))
	c := byTitle(t, checks, "site directory")
	want := []string{"chown -R git /var/www/htdocs/code.example.com", "chmod 755 /var/www/htdocs/code.example.com"}
	if c.Status != setup.Fix || !slices.Equal(c.Fix, want) {
		t.Errorf("site directory: %v %q, want %q", c.Status, c.Fix, want)
	}
	if c := byTitle(t, checks, "httpd serves the site"); c.Status != setup.Later {
		t.Errorf("serving is %v before the site directory is fixed", c.Status)
	}
}

func TestGitHomeElsewhere(t *testing.T) {
	f, e := ready(t)
	f.write("/etc/passwd", "root:*:0:0::/root:/bin/ksh\ngit:*:1001:1001::/home/git:/bin/ksh\n", 0o644, "")
	c := byTitle(t, setup.Run(e, options(f)), "git account")
	want := []string{"chsh -s /usr/local/bin/git-shell git", "usermod -d /var/git git"}
	if c.Status != setup.Fix || !slices.Equal(c.Fix, want) {
		t.Errorf("git account: %v %q, want %q", c.Status, c.Fix, want)
	}
}

func TestShellAddedWhenGitIsThere(t *testing.T) {
	f, e := ready(t)
	f.write("/etc/shells", "/bin/sh\n", 0o644, "")
	c := byTitle(t, setup.Run(e, options(f)), "git-shell is a login shell")
	if want := []string{"echo /usr/local/bin/git-shell >> /etc/shells"}; !slices.Equal(c.Fix, want) {
		t.Errorf("shells fix %q, want %q", c.Fix, want)
	}
}

func TestConfigMustBeRoots(t *testing.T) {
	f, e := ready(t)
	f.owners[config.DefaultPath] = "git:git"
	c := byTitle(t, setup.Run(e, options(f)), "configuration")
	if !slices.Contains(c.Fix, "chown root:wheel "+config.DefaultPath) {
		t.Errorf("a config git owns gets %q", c.Fix)
	}
}

func TestKeysGitDoesNotOwn(t *testing.T) {
	f, e := ready(t)
	f.owners["/var/git/.ssh/authorized_keys"] = "root:wheel"
	c := byTitle(t, setup.Run(e, options(f)), "SSH keys that may push")
	if c.Status != setup.Fix || !slices.Contains(c.Fix, "chown git:git /var/git/.ssh/authorized_keys") {
		t.Errorf("keys: %v %q", c.Status, c.Fix)
	}
}

func TestKeysUnreadableWithoutRoot(t *testing.T) {
	if os.Geteuid() == 0 {
		t.Skip("root reads it anyway")
	}
	f, e := ready(t)
	if err := os.Chmod(f.path("/var/git/.ssh/authorized_keys"), 0); err != nil {
		t.Fatal(err)
	}
	e.AsRoot = false
	if c := byTitle(t, setup.Run(e, options(f)), "SSH keys that may push"); c.Status != setup.Unknown {
		t.Errorf("unreadable keys are %v, want unknown", c.Status)
	}
}

func TestStaleShareFiles(t *testing.T) {
	f, e := ready(t)
	f.write(filepath.Join(setup.ShareDir, "hooks/post-receive"), "old hook", 0o755, "")
	o := options(f)
	c := byTitle(t, setup.Run(e, o), "hook, lexers and examples")
	want := []string{o.Self + " share-files | tar -xpf - -C " + setup.ShareDir}
	if c.Status != setup.Fix || !slices.Equal(c.Fix, want) {
		t.Errorf("share files: %v %q, want %q", c.Status, c.Fix, want)
	}
}

func TestNewerProgramToInstall(t *testing.T) {
	f, e := ready(t)
	f.write("/root/repo-render", "repo-render, newer", 0o755, "")
	o := options(f)
	o.Self = f.path("/root/repo-render")
	c := byTitle(t, setup.Run(e, o), "repo-render")
	if want := []string{"install -o root -g bin -m 755 " + o.Self + " " + setup.BinPath}; c.Status != setup.Fix || !slices.Equal(c.Fix, want) {
		t.Errorf("program: %v %q, want %q", c.Status, c.Fix, want)
	}
}

func TestSiteConfOutOfDate(t *testing.T) {
	f, e := ready(t)
	f.write(setup.IncludePath, "server \"code.example.com\" {}\n", 0o644, "")
	o := options(f)
	c := byTitle(t, setup.Run(e, o), "the site's httpd configuration")
	if want := []string{o.Self + " httpd-conf > " + setup.IncludePath}; c.Status != setup.Fix || !slices.Equal(c.Fix, want) {
		t.Errorf("site conf: %v %q, want %q", c.Status, c.Fix, want)
	}
}

func TestOtherServerForHost(t *testing.T) {
	f, e := ready(t)
	f.write(setup.HTTPDPath, "server \"code.example.com\" {\n\tlisten on * port 80\n}\n"+setup.IncludeLine+"\n", 0o644, "")
	c := byTitle(t, setup.Run(e, options(f)), "httpd.conf includes the site")
	if c.Status != setup.Fix || len(c.Fix) > 0 || !strings.Contains(c.Detail, "take it out") {
		t.Errorf("another server for the host: %v %q %s", c.Status, c.Fix, c.Detail)
	}
}

func TestTypesOnlyOnce(t *testing.T) {
	f, e := ready(t)
	cfg, _ := config.Parse([]byte("host: code.example.com\n"))
	b, _ := setup.SiteConf(e, cfg)
	if !bytes.Contains(b, []byte("types {")) {
		t.Error("with no types in httpd.conf, the site's has none")
	}
	f.write(setup.HTTPDPath, "types {\n\tinclude \"/usr/share/misc/mime.types\"\n}\n"+setup.IncludeLine+"\n", 0o644, "")
	b, _ = setup.SiteConf(e, cfg)
	if bytes.Contains(b, []byte("types {")) {
		t.Error("httpd.conf has types, and the site's has them again")
	}
}

func TestHTTPDRejects(t *testing.T) {
	f, e := ready(t)
	f.rejects = true
	c := byTitle(t, setup.Run(e, options(f)), "httpd accepts its configuration")
	if c.Status != setup.Fix || !strings.Contains(c.Detail, "syntax error") {
		t.Errorf("httpd -n failing: %v %s", c.Status, c.Detail)
	}
}

func TestRendererCheckFails(t *testing.T) {
	f, e := ready(t)
	f.checkFails = true
	c := byTitle(t, setup.Run(e, options(f)), "repo-render check, as git")
	if c.Status != setup.Fix || !strings.Contains(c.Detail, "plain text") {
		t.Errorf("repo-render check failing: %v %s", c.Status, c.Detail)
	}
}

func TestNotRendered(t *testing.T) {
	f, e := ready(t)
	os.Remove(f.path("/var/www/htdocs/code.example.com/index.html"))
	c := byTitle(t, setup.Run(e, options(f)), "the site is rendered")
	if want := []string{"su -s /bin/sh git -c 'repo-render render --all'"}; !slices.Equal(c.Fix, want) {
		t.Errorf("render fix %q, want %q", c.Fix, want)
	}
}

func TestServing(t *testing.T) {
	for _, tc := range []struct {
		name    string
		running bool
		status  int
		fix     string
	}{
		{"stopped", false, 200, "rcctl start httpd"},
		{"old configuration", true, 404, "rcctl reload httpd"},
	} {
		f, e := ready(t)
		f.running, f.status = tc.running, tc.status
		c := byTitle(t, setup.Run(e, options(f)), "httpd serves the site")
		if c.Status != setup.Fix || !slices.Equal(c.Fix, []string{tc.fix}) {
			t.Errorf("%s: %v %q, want %q", tc.name, c.Status, c.Fix, tc.fix)
		}
	}
}

func TestNotRootCannotTell(t *testing.T) {
	f, e := ready(t)
	e.AsRoot = false
	if c := byTitle(t, setup.Run(e, options(f)), "httpd accepts its configuration"); c.Status != setup.Unknown {
		t.Errorf("httpd -n without root is %v, want unknown", c.Status)
	}
}

func TestWriteShare(t *testing.T) {
	var b bytes.Buffer
	if err := setup.WriteShare(&b, share); err != nil {
		t.Fatal(err)
	}
	got := map[string]int64{}
	tr := tar.NewReader(&b)
	for {
		h, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			t.Fatal(err)
		}
		if h.Uname != "root" {
			t.Errorf("%s is %s's", h.Name, h.Uname)
		}
		got[h.Name] = h.Mode
	}
	want := map[string]int64{"hooks/": 0o755, "hooks/post-receive": 0o755, "lexers/": 0o755, "lexers/tomo.xml": 0o644, "repo-render.yaml": 0o644}
	for name, mode := range want {
		if m, ok := got[name]; !ok || m != mode {
			t.Errorf("%s: mode %o (there: %v), want %o", name, m, ok, mode)
		}
	}
	if len(got) != len(want) {
		t.Errorf("archive has %v", got)
	}
}

func TestOnlyKnownCommandsRun(t *testing.T) {
	if _, err := setup.RealRunner(&setup.Env{}, setup.Cmd{Argv: []string{"rm", "-rf", "/"}}); err == nil || !strings.Contains(err.Error(), "not one of the commands") {
		t.Errorf("running rm: %v", err)
	}
}

func TestReportWords(t *testing.T) {
	checks := []*setup.Check{
		{Title: "git", Status: setup.OK, Detail: "/usr/local/bin/git"},
		{Title: "git account", Status: setup.Fix, Detail: "does not exist", Fix: []string{"useradd git"}},
		{Title: "httpd serves the site", Status: setup.Later, Detail: "checked once the above are fixed"},
	}
	var plain, color bytes.Buffer
	if n := setup.Report(&plain, checks, false); n != 2 {
		t.Errorf("%d not done, want 2", n)
	}
	for _, want := range []string{"  OK    git ", "  FIX   git account ", "\n          useradd git\n", "  WAIT  httpd serves", "1 OK, 1 to fix, 1 waiting on those.", "under each FIX as root"} {
		if !strings.Contains(plain.String(), want) {
			t.Errorf("report lacks %q:\n%s", want, plain.String())
		}
	}
	if strings.ContainsAny(plain.String(), "\x1b✓✗·") {
		t.Errorf("plain report has color or non-ASCII marks:\n%s", plain.String())
	}
	setup.Report(&color, checks, true)
	for _, want := range []string{"\x1b[32mOK  \x1b[0m", "\x1b[1;31mFIX \x1b[0m", "\x1b[90mWAIT\x1b[0m"} {
		if !strings.Contains(color.String(), want) {
			t.Errorf("colored report lacks %q", want)
		}
	}
}

func TestNologinShell(t *testing.T) {
	f, e := ready(t)
	f.write("/etc/passwd", "root:*:0:0::/root:/bin/ksh\ngit:*:1001:1001::/var/git:/sbin/nologin\n", 0o644, "")
	c := byTitle(t, setup.Run(e, options(f)), "git account")
	if !strings.Contains(c.Detail, "cannot take pushes") || strings.Contains(c.Detail, "log in") {
		t.Errorf("nologin shell: %s", c.Detail)
	}
}

func TestHomeLeftBehind(t *testing.T) {
	f, e := fresh(t)
	f.write("/etc/passwd", "root:*:0:0::/root:/bin/ksh\ngit:*:1001:1001::/var/www/pub/git:/sbin/nologin\n", 0o644, "")
	f.mkdir("/var/www/pub/git/a.git", 0o755, "git:git")
	f.mkdir("/var/www/pub/git/b.git", 0o755, "git:git")
	f.write("/var/www/pub/git/.ssh/authorized_keys", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA bruce@laptop\n", 0o600, "git:git")
	checks := setup.Run(e, options(f))
	c := byTitle(t, checks, "git account")
	if !strings.Contains(c.Detail, "leaves behind 2 repositories and its .ssh") || !strings.Contains(c.Detail, "git_dir: /var/www/pub/git") {
		t.Errorf("moving git's home: %s", c.Detail)
	}
	c = byTitle(t, checks, "SSH keys that may push")
	if !slices.Contains(c.Fix, "cat /var/www/pub/git/.ssh/authorized_keys >> /var/git/.ssh/authorized_keys") {
		t.Errorf("keys do not come from git's old home: %q", c.Fix)
	}
}

func TestKeysWithNoneToCopy(t *testing.T) {
	f, e := fresh(t)
	os.Remove(f.path("/root/.ssh/authorized_keys"))
	c := byTitle(t, setup.Run(e, options(f)), "SSH keys that may push")
	want := []string{"install -d -o git -g git -m 700 /var/git/.ssh", "install -o git -g git -m 600 /dev/null /var/git/.ssh/authorized_keys"}
	if !slices.Equal(c.Fix, want) || !strings.Contains(c.Detail, "one per line") {
		t.Errorf("keys with none to copy: %q (%s), want %q", c.Fix, c.Detail, want)
	}
}

func TestKeysOfUserRunningIt(t *testing.T) {
	f, e := fresh(t)
	f.env["USER"] = "bruce"
	f.write("/home/bruce/.ssh/authorized_keys", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA bruce@laptop\n", 0o600, "")
	c := byTitle(t, setup.Run(e, options(f)), "SSH keys that may push")
	if !slices.Contains(c.Fix, "cat /home/bruce/.ssh/authorized_keys >> /var/git/.ssh/authorized_keys") {
		t.Errorf("keys do not come from the user running it: %q", c.Fix)
	}
}