Files
mesh-host/internal/apply/groups_test.go
T
jochen ab4ca44f98
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/module-groups delivering: 0 of 2 delivered
mesh/delivery delivered
Give back only the groups the mesh added, and say when a new login is needed (hq ADR 0252, issue 247)
A module puts the operator's account in a group by declaring the account with that group alone.
The node-engine now records each group it added, takes back only those when nothing declared still
asks for them, refuses a group the machine lacks before usermod runs, and states each such account
as its module's resource of kind account: relogin needed while the running session lacks the group.
2026-10-08 12:04:08 +02:00

232 lines
7.7 KiB
Go

package apply
import (
"context"
"errors"
"sort"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// Defends novox/hq ADR 0252 and issue 247: a module puts the operator's account in a group by declaring
// the account with that group alone; the account's other groups are never touched; a new login is said;
// and the mesh gives back only a group it put the account in, and only when nobody declared still asks.
// groupDB is a fake user and group database: the account's groups, the groups the machine has, and every
// command it was asked.
type groupDB struct {
account string
in map[string]bool
exists map[string]bool
asked []string
}
func newGroupDB(in []string, exists ...string) *groupDB {
g := &groupDB{account: "operator", in: map[string]bool{}, exists: map[string]bool{}}
for _, x := range in {
g.in[x], g.exists[x] = true, true
}
for _, x := range exists {
g.exists[x] = true
}
return g
}
func (g *groupDB) run(_ context.Context, name string, args ...string) (string, error) {
g.asked = append(g.asked, name+" "+strings.Join(args, " "))
switch {
case name == "getent" && args[0] == "passwd":
if args[1] == g.account {
return g.account + ":x:1500:1500::/home/" + g.account + ":/bin/bash\n", nil
}
return "", errors.New("getent exited 2: ")
case name == "getent" && args[0] == "group":
if g.exists[args[1]] {
return args[1] + ":x:900:\n", nil
}
return "", errors.New("getent exited 2: ")
case name == "id":
var out []string
for x := range g.in {
out = append(out, x)
}
sort.Strings(out)
return strings.Join(out, " ") + "\n", nil
case name == "usermod" && args[0] == "--append":
if !g.exists[args[2]] {
return "", errors.New("usermod exited 6: group '" + args[2] + "' does not exist")
}
g.in[args[2]] = true
case name == "gpasswd" && args[0] == "--delete":
delete(g.in, args[2])
}
return "", nil
}
func (g *groupDB) groups() string {
var out []string
for x := range g.in {
out = append(out, x)
}
sort.Strings(out)
return strings.Join(out, " ")
}
func applyGroupsOf(t *testing.T, g *groupDB, known store.State, resources ...string) (Report, store.State, error) {
t.Helper()
if len(resources) == 0 {
resources = []string{`{"id":"other.dir","type":"directory","path":"` + t.TempDir() + `/other"}`}
}
return Apply(context.Background(), archHost(t), parse(t, `{"declaration":1,"resources":[`+
strings.Join(resources, ",")+`]}`), known, store.OriginDeclared, g.run, nil, nil)
}
const (
razer = `{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer"]}`
docker = `{"id":"docker.account","type":"user","name":"operator","groups":["docker"]}`
shell = `{"id":"zsh.login","type":"user","name":"operator","groups":[]}`
)
func TestAModulePutsTheAccountInAGroupAndSaysANewLoginIsNeeded(t *testing.T) {
g := newGroupDB([]string{"wheel", "plugdev"}, "openrazer")
report, state, err := applyGroupsOf(t, g, store.State{}, shell, razer)
if err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "openrazer plugdev wheel" {
t.Fatalf("the account's groups are %q", got)
}
o := outcomeOf(report, "openrazer.account")
if o.Action != "updated" || !strings.Contains(o.Detail, "put in openrazer") || !strings.Contains(o.Detail, "new login") {
t.Errorf("the outcome did not say the group and the new login: %+v", o)
}
a, _ := state.Find("openrazer.account")
if strings.Join(a.Groups, " ") != "openrazer" {
t.Errorf("the record holds %v, want the one group the mesh added", a.Groups)
}
for _, asked := range g.asked {
if strings.HasPrefix(asked, "usermod") && !strings.HasPrefix(asked, "usermod --append --groups openrazer ") {
t.Errorf("usermod was asked something other than appending the one group: %q", asked)
}
}
// Applied again: nothing to do, and the record still says the mesh added it.
report, state, err = applyGroupsOf(t, g, state, shell, razer)
if err != nil {
t.Fatal(err)
}
if o := outcomeOf(report, "openrazer.account"); o.Action != "unchanged" {
t.Errorf("a second apply changed something: %+v", o)
}
if a, _ := state.Find("openrazer.account"); strings.Join(a.Groups, " ") != "openrazer" {
t.Errorf("a second apply lost what the mesh added: %v", a.Groups)
}
}
func TestAGroupTheAccountWasAlreadyInIsNeverTakenBack(t *testing.T) {
g := newGroupDB([]string{"wheel", "openrazer"})
_, state, err := applyGroupsOf(t, g, store.State{}, razer)
if err != nil {
t.Fatal(err)
}
if a, _ := state.Find("openrazer.account"); len(a.Groups) != 0 {
t.Fatalf("a group found was recorded as the mesh's: %v", a.Groups)
}
if _, _, err := applyGroupsOf(t, g, state); err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "openrazer wheel" {
t.Errorf("undeclaring took a found group: %q", got)
}
}
func TestTheGroupTheMeshAddedIsGivenBackWhenItsModuleGoes(t *testing.T) {
g := newGroupDB([]string{"wheel"}, "openrazer")
_, state, err := applyGroupsOf(t, g, store.State{}, razer)
if err != nil {
t.Fatal(err)
}
report, state, err := applyGroupsOf(t, g, state)
if err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "wheel" {
t.Errorf("the account's groups after its module went: %q, want wheel alone", got)
}
o := outcomeOf(report, "openrazer.account")
if o.Action != "restored" || !strings.Contains(o.Detail, "taken out of openrazer") {
t.Errorf("the removal did not say the group was given back: %+v", o)
}
if _, still := state.Find("openrazer.account"); still {
t.Error("the record stayed")
}
}
func TestAGroupAnotherResourceStillAsksForStays(t *testing.T) {
both := `{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer","video"]}`
video := `{"id":"media.account","type":"user","name":"operator","groups":["video"]}`
g := newGroupDB(nil, "openrazer", "video")
_, state, err := applyGroupsOf(t, g, store.State{}, both, video)
if err != nil {
t.Fatal(err)
}
report, _, err := applyGroupsOf(t, g, state, video)
if err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "video" {
t.Errorf("the account's groups: %q, want video kept for media and openrazer given back", got)
}
if o := outcomeOf(report, "openrazer.account"); !strings.Contains(o.Detail, "media.account still asks for") {
t.Errorf("the removal did not say why video stayed: %+v", o)
}
}
func TestAGroupNoLongerDeclaredIsGivenBackWhileTheAccountStaysDeclared(t *testing.T) {
g := newGroupDB(nil, "openrazer", "input")
two := `{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer","input"]}`
_, state, err := applyGroupsOf(t, g, store.State{}, two)
if err != nil {
t.Fatal(err)
}
_, state, err = applyGroupsOf(t, g, state, razer)
if err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "openrazer" {
t.Errorf("the account's groups: %q, want input given back", got)
}
if a, _ := state.Find("openrazer.account"); strings.Join(a.Groups, " ") != "openrazer" {
t.Errorf("the record holds %v", a.Groups)
}
}
func TestAGroupThatDoesNotExistYetFailsTheResourceSayingSo(t *testing.T) {
g := newGroupDB([]string{"wheel"})
_, _, err := applyGroupsOf(t, g, store.State{}, razer)
if err == nil || !strings.Contains(err.Error(), "no such group yet") {
t.Fatalf("a missing group was not said: %v", err)
}
for _, asked := range g.asked {
if strings.HasPrefix(asked, "usermod") {
t.Errorf("usermod was run for a group the machine does not have: %q", asked)
}
}
}
func TestAGroupAPersonTookTheAccountOutOfSinceIsPutBackWhileDeclared(t *testing.T) {
g := newGroupDB(nil, "openrazer")
_, state, err := applyGroupsOf(t, g, store.State{}, razer)
if err != nil {
t.Fatal(err)
}
delete(g.in, "openrazer")
if _, _, err := applyGroupsOf(t, g, state, razer); err != nil {
t.Fatal(err)
}
if got := g.groups(); got != "openrazer" {
t.Errorf("a declared group was not put back: %q", got)
}
}