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