A user had no removal, so an undeclared one failed as an orphan and aborted every apply after. Removal now keeps the account, gives back the shell recorded when the mesh first changed it if it is still the mesh's and still usable, and says why otherwise (hq ADR 0176 §2). A shell is refused before it is set unless it is executable and listed in /etc/shells, since usermod succeeds on a missing one.
295 lines
10 KiB
Go
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 225: 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 225 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"])
|
|
}
|
|
}
|