package apply import ( "context" "errors" "os" "path/filepath" "strings" "testing" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) // Defends novox/hq ADR 0176 §2 and issue 228: a login the mesh set is given back when its holding // moves, undeclaring one never stops the node applying, and a shell is checked before it is set. // logins is a fake user database: each account's shell by name, and every command it was asked. type logins struct { shells map[string]string asked []string } func (l *logins) run(_ context.Context, name string, args ...string) (string, error) { l.asked = append(l.asked, name+" "+strings.Join(args, " ")) who := args[len(args)-1] switch name { case "getent": if shell, ok := l.shells[who]; ok { return who + ":x:1500:1500::/home/" + who + ":" + shell + "\n", nil } return "", errors.New("getent exited 2: ") // the host's runner's words for "no such key" case "useradd": shell := "" for i, a := range args { if a == "--shell" { shell = args[i+1] } } l.shells[who] = shell case "usermod": if args[0] == "--shell" { l.shells[who] = args[1] } case "userdel": delete(l.shells, who) case "id": return "\n", nil } return "", nil } func (l *logins) did(prefix string) bool { for _, a := range l.asked { if strings.HasPrefix(a, prefix) { return true } } return false } // shellsOn makes a machine's shells in a directory a test owns: each name an executable file, // listed or not in the machine's list of shells as said, which this test's apply then reads. func shellsOn(t *testing.T, listed []string, unlisted ...string) string { t.Helper() dir := t.TempDir() var list strings.Builder list.WriteString("# Pathnames of valid login shells.\n") for _, name := range append(append([]string{}, listed...), unlisted...) { if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) } } for _, name := range listed { list.WriteString(filepath.Join(dir, name) + "\n") } if err := os.WriteFile(filepath.Join(dir, "shells"), []byte(list.String()), 0o644); err != nil { t.Fatal(err) } t.Cleanup(system.ShellsIn(filepath.Join(dir, "shells"))) return dir } func applyUsers(t *testing.T, l *logins, known store.State, resources string) (Report, store.State, error) { t.Helper() if resources == "" { // Undeclared: something else stays, since a declaration with nothing in it is refused. resources = `{"id":"other.dir","type":"directory","path":"` + t.TempDir() + `/other"}` } return Apply(context.Background(), archHost(t), parse(t, `{"declaration":1,"resources":[`+resources+`]}`), known, store.OriginDeclared, l.run, nil, nil) } func userWith(shell string) string { return `{"id":"shell.login","type":"user","name":"operator","shell":"` + shell + `"}` } func TestAnUndeclaredUserNoLongerStopsTheApply(t *testing.T) { // Before issue 228 the host had no removal for a user, the orphan failed with "no way to // remove", and an orphan's failure aborts the apply before its first resource — on every // apply after, since the record stayed. dir := shellsOn(t, []string{"bash", "zsh"}) l := &logins{shells: map[string]string{"operator": dir + "/bash"}} _, state, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh")) if err != nil { t.Fatal(err) } page := filepath.Join(t.TempDir(), "page") report, state, err := applyUsers(t, l, state, `{"id":"web.page","type":"file","path":"`+page+`","content":"hello\n"}`) if err != nil { t.Fatalf("an undeclared user stopped the apply: %v", err) } if _, err := os.Stat(page); err != nil { t.Errorf("a file in the same declaration was not written: %v", err) } if o := outcomeOf(report, "shell.login"); o.Action == "" { t.Errorf("the user's removal was not reported: %+v", report.Outcomes) } if _, still := state.Find("shell.login"); still { t.Error("the user is still recorded, so the next apply would meet it again") } } func TestTheShellFoundIsGivenBackWhenTheUserIsUndeclared(t *testing.T) { dir := shellsOn(t, []string{"bash", "zsh"}) l := &logins{shells: map[string]string{"operator": dir + "/bash"}} _, state, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh")) if err != nil { t.Fatal(err) } if l.shells["operator"] != dir+"/zsh" { t.Fatalf("the declared shell was not set: %q", l.shells["operator"]) } if r, _ := state.Find("shell.login"); r.Shell == nil || r.Shell.Found != dir+"/bash" { t.Fatalf("the shell the account had was not recorded: %+v", r.Shell) } report, _, err := applyUsers(t, l, state, "") if err != nil { t.Fatal(err) } if l.shells["operator"] != dir+"/bash" { t.Errorf("the shell the account had was not given back: %q", l.shells["operator"]) } if o := outcomeOf(report, "shell.login"); o.Action != "restored" { t.Errorf("the give-back was not said: %+v", o) } if l.did("userdel") { t.Error("the account was deleted") } } func TestAShellAPersonChangedSinceIsLeftAlone(t *testing.T) { dir := shellsOn(t, []string{"bash", "zsh", "fish"}) l := &logins{shells: map[string]string{"operator": dir + "/bash"}} _, state, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh")) if err != nil { t.Fatal(err) } l.shells["operator"] = dir + "/fish" // chsh, by the person whose login it is l.asked = nil report, _, err := applyUsers(t, l, state, "") if err != nil { t.Fatal(err) } if l.did("usermod") || l.shells["operator"] != dir+"/fish" { t.Errorf("a shell a person chose was taken from them: %q, %v", l.shells["operator"], l.asked) } if o := outcomeOf(report, "shell.login"); o.Action != "forgotten" || !strings.Contains(o.Detail, "changed since") { t.Errorf("the outcome does not say why the shell was left: %+v", o) } } func TestAFoundShellThatIsGoneIsNotGivenBack(t *testing.T) { // Giving back a shell uninstalled since would break the logins the giving back is for. dir := shellsOn(t, []string{"bash", "zsh"}) l := &logins{shells: map[string]string{"operator": dir + "/bash"}} _, state, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh")) if err != nil { t.Fatal(err) } if err := os.Remove(dir + "/bash"); err != nil { t.Fatal(err) } l.asked = nil report, _, err := applyUsers(t, l, state, "") if err != nil { t.Fatal(err) } if l.did("usermod") || l.shells["operator"] != dir+"/zsh" { t.Errorf("a shell no longer on the machine was given back: %q", l.shells["operator"]) } if o := outcomeOf(report, "shell.login"); !strings.Contains(o.Detail, "cannot be given back") { t.Errorf("the outcome does not say why the shell was left: %+v", o) } } func TestAShellThatIsMissingOrUnlistedIsRefusedBeforeItIsSet(t *testing.T) { dir := shellsOn(t, []string{"bash"}, "unlisted") for name, shell := range map[string]string{ "missing": dir + "/zsh", "unlisted": dir + "/unlisted", } { t.Run(name, func(t *testing.T) { l := &logins{shells: map[string]string{"operator": dir + "/bash"}} page := filepath.Join(t.TempDir(), "page") report, state, err := applyUsers(t, l, store.State{}, userWith(shell)+`, {"id":"web.page","type":"file","path":"`+page+`","content":"hello\n"}`) if err == nil { t.Fatal("the refused shell did not fail its resource") } if l.did("usermod") || l.shells["operator"] != dir+"/bash" { t.Errorf("the account was changed: %q, %v", l.shells["operator"], l.asked) } if _, recorded := state.Find("shell.login"); recorded { t.Error("a refused user was recorded") } if o := outcomeOf(report, "web.page"); o.Action != "created" { t.Errorf("the refusal stopped the rest of the declaration: %+v", report.Outcomes) } }) } t.Run("an account not yet made", func(t *testing.T) { l := &logins{shells: map[string]string{}} if _, _, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh")); err == nil { t.Fatal("the missing shell was not refused") } if l.did("useradd") { t.Errorf("the account was made with a shell that is not there: %v", l.asked) } }) } func TestAnAccountThatRefusesLoginsNeedNotBeListed(t *testing.T) { // A service's account has nologin, which no distribution lists among its shells; refusing it // would refuse the controller's own account. dir := shellsOn(t, []string{"bash"}, "nologin") l := &logins{shells: map[string]string{}} if _, _, err := applyUsers(t, l, store.State{}, userWith(dir+"/nologin")); err != nil { t.Fatalf("a service account was refused: %v", err) } if l.shells["operator"] != dir+"/nologin" { t.Errorf("the account was not made: %v", l.asked) } } func TestACreatedAccountSurvivesItsRemoval(t *testing.T) { dir := shellsOn(t, []string{"zsh"}) l := &logins{shells: map[string]string{}} report, state, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh")) if err != nil { t.Fatal(err) } if o := outcomeOf(report, "shell.login"); o.Action != "created" { t.Fatalf("the account was not created: %+v", o) } l.asked = nil report, _, err = applyUsers(t, l, state, "") if err != nil { t.Fatal(err) } if _, still := l.shells["operator"]; !still || l.did("userdel") || l.did("usermod") { t.Errorf("a created account was not left as it is: %v", l.asked) } if o := outcomeOf(report, "shell.login"); !strings.Contains(o.Detail, "account is kept") { t.Errorf("the outcome does not say the account was kept: %+v", o) } } func TestTheFoundShellIsNotOverwrittenByASecondChange(t *testing.T) { // The holding moves from one shell module to another: what is given back in the end is the // shell from before the mesh, not the first module's. dir := shellsOn(t, []string{"bash", "zsh", "fish"}) l := &logins{shells: map[string]string{"operator": dir + "/bash"}} _, state, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh")) if err != nil { t.Fatal(err) } _, state, err = applyUsers(t, l, state, userWith(dir+"/fish")) if err != nil { t.Fatal(err) } if r, _ := state.Find("shell.login"); r.Shell == nil || r.Shell.Found != dir+"/bash" || r.Shell.Set != dir+"/fish" { t.Fatalf("the record is not the shell found and the one set last: %+v", r.Shell) } if _, _, err := applyUsers(t, l, state, ""); err != nil { t.Fatal(err) } if l.shells["operator"] != dir+"/bash" { t.Errorf("given back %q, not the shell from before the mesh", l.shells["operator"]) } }