The official package in place of lemurs-git, and /etc/lemurs/config.toml in lemurs 0.4's structure. It offers only the session scripts that modules place in /etc/lemurs/wms and /etc/lemurs/wayland, never a package's bare desktop entry, which skips the session's start. The service is enabled and never started, stopped or restarted by a push. The tools are the seat's sessions, the default session (lemurs's cache, through sudo -n) and logins from the journal and lemurs's own log. The package swap from lemurs-git is a one-off step for the operator, listed in the README.
145 lines
5.6 KiB
Go
145 lines
5.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"lemurs/internal/desktop"
|
|
)
|
|
|
|
// aMachine lays out a login manager in a directory: its configuration naming directories under it,
|
|
// two X scripts (one not executable), a package's desktop entry and a cache.
|
|
func aMachine(t *testing.T) (lemurs, string, *[]string) {
|
|
root := t.TempDir()
|
|
for _, d := range []string{"wms", "wayland", "xsessions", "wayland-sessions"} {
|
|
os.MkdirAll(filepath.Join(root, d), 0o755)
|
|
}
|
|
os.WriteFile(filepath.Join(root, "wms", "i3"), []byte("#!/bin/sh\n# the session\nexec /bin/sh \"$HOME/.xinitrc\"\n"), 0o755)
|
|
os.WriteFile(filepath.Join(root, "wms", "notes"), []byte("not a session\n"), 0o644)
|
|
os.WriteFile(filepath.Join(root, "xsessions", "i3.desktop"), []byte("[Desktop Entry]\nName=i3\nExec=i3\nType=Application\n"), 0o644)
|
|
os.WriteFile(filepath.Join(root, "cache"), []byte("i3\nop\n"), 0o644)
|
|
config := strings.Join([]string{
|
|
`tty = 2`, `cache_path = "` + root + `/cache"`,
|
|
`[x11]`, `x11_display = ":1"`, `scripts_path = "` + root + `/wms"`, `xsessions_path = "` + root + `/xsessions"`,
|
|
`[wayland]`, `scripts_path = "` + root + `/wayland"`, `wayland_sessions_path = "` + root + `/wayland-sessions"`,
|
|
}, "\n")
|
|
os.WriteFile(filepath.Join(root, "config.toml"), []byte(config), 0o644)
|
|
var ran []string
|
|
d := desktop.Desk{
|
|
Find: func() (*desktop.Session, error) { return nil, &desktop.NoSession{Reason: "none"} },
|
|
Run: func(_ context.Context, _ []string, stdin []byte, name string, args ...string) desktop.Result {
|
|
line := strings.Join(append([]string{name}, args...), " ")
|
|
ran = append(ran, line+" <<"+string(stdin))
|
|
if name == "journalctl" {
|
|
return desktop.Result{Stdout: journal}
|
|
}
|
|
return desktop.Result{}
|
|
},
|
|
}
|
|
return lemurs{d: d, config: filepath.Join(root, "config.toml"), log: filepath.Join(root, "lemurs.log"), uid: 1000}, root, &ran
|
|
}
|
|
|
|
const journal = `-- Boot a0 --
|
|
2026-10-02T13:08:44+02:00 host lemurs[1001]: gkr-pam: unable to locate daemon control file
|
|
2026-10-02T13:08:40+02:00 host lemurs[1001]: pam_unix(lemurs:auth): authentication failure; logname= uid=0 euid=0 tty=tty2 ruser= rhost= user=op
|
|
2026-10-02T13:08:44+02:00 host lemurs[2141]: pam_unix(lemurs:session): session opened for user op(uid=1000) by op(uid=0)
|
|
2026-10-02T18:00:01+02:00 host lemurs[2141]: pam_unix(lemurs:session): session closed for user op
|
|
`
|
|
|
|
func TestTheSessionsAreTheEntriesLemursOffersInItsOrder(t *testing.T) {
|
|
l, root, _ := aMachine(t)
|
|
got, err := l.sessions(context.Background(), desktop.Args{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := got.(map[string]any)["sessions"].([]Session)
|
|
if len(s) != 3 || s[0].Source != "desktop-entry" || s[0].Exec != "i3" || s[1].Name != "i3" || s[1].Source != "script" {
|
|
t.Fatalf("%+v", s)
|
|
}
|
|
if s[1].Exec != `exec /bin/sh "$HOME/.xinitrc"` || !s[1].Offered {
|
|
t.Fatalf("a script answers what it runs: %+v", s[1])
|
|
}
|
|
if s[2].Name != "notes" || s[2].Executable || s[2].Offered {
|
|
t.Fatalf("a script that is not executable is not offered: %+v", s[2])
|
|
}
|
|
if d := got.(map[string]any)["default"].(Cached); d.Session != "i3" || d.Account != "op" {
|
|
t.Fatalf("%+v", d)
|
|
}
|
|
_ = root
|
|
}
|
|
|
|
func TestTheModulesConfigurationIsReadAsLemursReadsIt(t *testing.T) {
|
|
raw, err := os.ReadFile(filepath.Join("..", "..", "config", "config.toml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := ParseConfig(string(raw))
|
|
want := Config{Cache: "/var/cache/lemurs", X11Scripts: "/etc/lemurs/wms", X11Sessions: "/etc/lemurs/xsessions",
|
|
WaylandScripts: "/etc/lemurs/wayland", WaylandEntries: "/etc/lemurs/wayland-sessions", Display: ":1", TTY: 2}
|
|
if c != want {
|
|
t.Fatalf("%+v", c)
|
|
}
|
|
}
|
|
|
|
func TestTheDefaultSessionIsOneLemursOffersAndIsWrittenThroughSudo(t *testing.T) {
|
|
l, root, ran := aMachine(t)
|
|
if _, err := l.defaultSession(context.Background(), desktop.Args{"session": "notes"}); err == nil {
|
|
t.Fatal("a session lemurs does not offer is refused")
|
|
}
|
|
if _, err := l.defaultSession(context.Background(), desktop.Args{"session": "i3", "account": "op; rm"}); err == nil {
|
|
t.Fatal("an account that is not a name is refused")
|
|
}
|
|
got, err := l.defaultSession(context.Background(), desktop.Args{"session": "i3"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(*ran) != 1 || (*ran)[0] != "sudo -n tee "+root+"/cache <<i3\nop\n" {
|
|
t.Fatalf("%q", *ran)
|
|
}
|
|
if !strings.Contains(asJSON(got), `"shown"`) {
|
|
t.Fatalf("it says when it shows: %s", asJSON(got))
|
|
}
|
|
l.uid = 0
|
|
*ran = nil
|
|
l.defaultSession(context.Background(), desktop.Args{"session": "i3"})
|
|
if !strings.HasPrefix((*ran)[0], "tee ") {
|
|
t.Fatalf("as root, no sudo: %q", *ran)
|
|
}
|
|
}
|
|
|
|
func TestLoginsAreReadFromTheJournalAndLemursLog(t *testing.T) {
|
|
l, root, _ := aMachine(t)
|
|
os.WriteFile(filepath.Join(root, "lemurs.log"), []byte(
|
|
"[2026-10-02T11:08:44Z INFO lemurs] Starting new session for 'op' in environment 'X { xinitrc_path: \"i3\" }'\n"+
|
|
"[2026-10-02T11:08:44Z INFO lemurs::auth] Login attempt for 'op'\n"), 0o644)
|
|
got, err := l.logins(context.Background(), desktop.Args{"limit": float64(2)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := got.(map[string]any)
|
|
ev := m["events"].([]Login)
|
|
if len(ev) != 2 || ev[0].Event != "opened" || ev[1].Event != "closed" || ev[1].Account != "op" || m["cut"] != true {
|
|
t.Fatalf("the newest two, cut: %+v", ev)
|
|
}
|
|
all := ParseJournal(journal)
|
|
if len(all) != 3 || all[0].Event != "failed" || all[0].Account != "op" {
|
|
t.Fatalf("%+v", all)
|
|
}
|
|
st := m["started"].([]Started)
|
|
if len(st) != 1 || st[0].Environment != `X { xinitrc_path: "i3" }` {
|
|
t.Fatalf("%+v", st)
|
|
}
|
|
if _, err := l.logins(context.Background(), desktop.Args{"since": "-1d; reboot"}); err == nil {
|
|
t.Fatal("since is a time")
|
|
}
|
|
}
|
|
|
|
func asJSON(v any) string {
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
}
|