Files
mesh-host/internal/apply/user_test.go
T

295 lines
10 KiB
Go

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"])
}
}