Files
nasmon/internal/shares/shares_test.go
T
a.gritsenkoandClaude Sonnet 5 09159a5f31 Initial import: nasmon health monitor, split out of home_nas
Go service (nasmon/) plus nfpm packaging (packaging/), carried over from
the home_nas monorepo. Adds rpm packaging alongside the existing deb
build (Fedora/RHEL/Rocky dependency names via nfpm overrides, per-format
systemd unit path, dpkg+rpm-compatible maintainer scripts) and a Gitea
Actions workflow that builds both formats on every push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:24:34 +03:00

339 lines
10 KiB
Go

package shares
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
)
func sample() Config {
c := Defaults()
c.SMB.Shares = []SMBShare{
{Name: "documents", Path: "/nas_pool_1_8_gb/documents", Comment: "Docs", Guest: true, Browseable: true, HideSysFiles: true},
{Name: "private", Path: "/nas_pool_4_gb/backups", ReadOnly: true, Users: []string{"alice", "bob"}, Browseable: false},
}
c.NFS.Exports = []NFSExport{
{Path: "/nas_pool_1_8_gb/documents", Clients: []string{"192.168.101.0/24", "pc.lan"}, Squash: "root"},
{Path: "/tmp_pool/downloads", Clients: []string{"*"}, ReadOnly: true, Squash: "all", Async: true},
}
return c
}
func TestRenderSMB(t *testing.T) {
out := RenderSMB(sample(), "ansible")
for _, want := range []string{
"[global]", "workgroup = WORKGROUP", "map to guest = Bad User", "server min protocol = SMB2",
"[documents]", "path = /nas_pool_1_8_gb/documents", "comment = Docs", "guest ok = yes", "read only = no", "browseable = yes",
"veto files = /._*/.DS_Store/Thumbs.db/desktop.ini/",
"[private]", "guest ok = no", "valid users = alice bob", "read only = yes", "browseable = no",
"force user = ansible", "force group = ansible", "create mask = 0664",
} {
if !strings.Contains(out, want) {
t.Errorf("smb.conf missing %q\n%s", want, out)
}
}
if strings.Count(out, "valid users") != 1 {
t.Errorf("guest share must not list valid users")
}
if strings.Count(out, "\n veto files = ") != 1 {
t.Errorf("veto files only where requested")
}
}
func TestRenderExports(t *testing.T) {
out := RenderExports(sample(), "1000", "1000")
want := []string{
`"/nas_pool_1_8_gb/documents" 192.168.101.0/24(rw,sync,root_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check) pc.lan(rw,sync,root_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check)`,
`"/tmp_pool/downloads" *(ro,async,all_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check)`,
}
for _, w := range want {
if !strings.Contains(out, w) {
t.Errorf("exports missing %q\n%s", w, out)
}
}
none := Config{NFS: NFSConfig{Exports: []NFSExport{{Path: "/x", Clients: []string{"h"}, Squash: "none"}}}}
if !strings.Contains(RenderExports(none, "1", "1"), "no_root_squash") || strings.Contains(RenderExports(none, "1", "1"), "anonuid") {
t.Errorf("squash=none rendering wrong")
}
}
func TestValidation(t *testing.T) {
for _, n := range []string{"docs", "My.Share-1", "a"} {
if err := ValidateShareName(n); err != nil {
t.Errorf("%q rejected: %v", n, err)
}
}
for _, n := range []string{"", "global", "Homes", "print$", "-x", "a b", "a/b", strings.Repeat("x", 81), "a[b]"} {
if ValidateShareName(n) == nil {
t.Errorf("%q accepted", n)
}
}
for _, p := range []string{"/nas/x", "/tmp_pool"} {
if err := ValidatePath(p); err != nil {
t.Errorf("%q rejected: %v", p, err)
}
}
for _, p := range []string{"", "/", "nas/x", "/nas/../etc", "/nas/", "/nas/x\"y", "/nas/x\n"} {
if ValidatePath(p) == nil {
t.Errorf("%q accepted", p)
}
}
if !UnderRoots("/nas_pool/docs/sub", []string{"/tmp_pool", "/nas_pool"}) || UnderRoots("/nas_pool2", []string{"/nas_pool"}) || UnderRoots("/etc", []string{"/"}) {
t.Errorf("UnderRoots")
}
for _, c := range []string{"*", "192.168.1.5", "192.168.1.0/24", "pc.lan", "*.lan", "fe80::1"} {
if err := ValidateClient(c); err != nil {
t.Errorf("%q rejected: %v", c, err)
}
}
for _, c := range []string{"", "192.168.1.0/33", "a b", "(rw)", "host,other"} {
if ValidateClient(c) == nil {
t.Errorf("%q accepted", c)
}
}
for _, u := range []string{"alice", "bob_2", "x-y"} {
if err := ValidateUserName(u); err != nil {
t.Errorf("%q rejected: %v", u, err)
}
}
for _, u := range []string{"", "Alice", "root", "nobody", "1abc", "a b"} {
if ValidateUserName(u) == nil {
t.Errorf("%q accepted", u)
}
}
c := sample()
if err := c.Validate(); err != nil {
t.Fatalf("sample invalid: %v", err)
}
c.SMB.Shares = append(c.SMB.Shares, SMBShare{Name: "documents", Path: "/x"})
if c.Validate() == nil {
t.Errorf("duplicate share accepted")
}
c = sample()
c.SMB.Shares[1].Users = nil
if c.Validate() == nil {
t.Errorf("non-guest share without users accepted")
}
c = sample()
c.NFS.Exports[0].Squash = "maybe"
if c.Validate() == nil {
t.Errorf("bad squash accepted")
}
if got := SplitList(" a, b;c\n d "); strings.Join(got, "|") != "a|b|c|d" {
t.Errorf("SplitList = %v", got)
}
}
func TestStoreRoundTrip(t *testing.T) {
p := filepath.Join(t.TempDir(), "shares.json")
st, err := Load(p)
if err != nil {
t.Fatal(err)
}
if err := st.Save(sample()); err != nil {
t.Fatal(err)
}
st2, err := Load(p)
if err != nil {
t.Fatal(err)
}
c := st2.Get()
if len(c.SMB.Shares) != 2 || len(c.NFS.Exports) != 2 || c.FindSMB("private") != 1 || c.FindNFS("/tmp_pool/downloads") != 1 {
t.Errorf("round trip: %+v", c)
}
// Get returns a copy
c.SMB.Shares[0].Name = "changed"
if st2.Get().SMB.Shares[0].Name == "changed" {
t.Errorf("Get leaked internal state")
}
}
// fake records host commands; stdin is captured for writes.
type fake struct {
calls []string
stdin map[string]string
fail map[string]string
reply map[string]string
lookup map[string]bool
}
func newFake() *fake {
return &fake{stdin: map[string]string{}, fail: map[string]string{}, reply: map[string]string{}, lookup: map[string]bool{"smbd": true, "exportfs": true}}
}
func (f *fake) run(_ context.Context, stdin, name string, args ...string) (string, error) {
c := name + " " + strings.Join(args, " ")
f.calls = append(f.calls, c)
if stdin != "" {
f.stdin[c] = stdin
}
for k, v := range f.fail {
if strings.HasPrefix(c, k) {
return v, errors.New(v)
}
}
for k, v := range f.reply {
if strings.HasPrefix(c, k) {
return v, nil
}
}
return "", nil
}
func (f *fake) lookPath(bin string) (string, error) {
if f.lookup[bin] {
return "/usr/sbin/" + bin, nil
}
return "", errors.New("not found")
}
func (f *fake) has(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func (f *fake) index(prefix string) int {
for i, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return i
}
}
return -1
}
func newManager(f *fake) *Manager {
m := New(f.run)
m.LookPath = f.lookPath
m.Owner, m.UID, m.GID = "ansible", "1000", "1000"
return m
}
func TestApplyOrder(t *testing.T) {
f := newFake()
m := newManager(f)
if err := m.Apply(sample()); err != nil {
t.Fatalf("apply: %v\n%v", err, f.calls)
}
// smb.conf written through sh with the rendered content on stdin
wi := f.index("sh -c")
if wi < 0 || !strings.Contains(f.calls[wi], DefaultSMBConf) {
t.Fatalf("smb.conf not written: %v", f.calls)
}
if !strings.Contains(f.stdin[f.calls[wi]], "[documents]") {
t.Errorf("stdin lacks rendered config")
}
ti, si, ri := f.index("testparm -s"), f.index("systemctl enable --now smbd nmbd"), f.index("smbcontrol all reload-config")
if !(wi < ti && ti < si && si < ri) {
t.Errorf("SMB order wrong: %v", f.calls)
}
ni, ei := f.index("systemctl enable --now nfs-server"), f.index("exportfs -ra")
if ni < 0 || ei < ni {
t.Errorf("NFS order wrong: %v", f.calls)
}
// exports written
found := false
for c, in := range f.stdin {
if strings.Contains(c, DefaultExports) && strings.Contains(in, "crossmnt") {
found = true
}
}
if !found {
t.Errorf("exports not written: %v", f.stdin)
}
}
func TestApplyTestparmFailureRestores(t *testing.T) {
f := newFake()
f.fail["testparm"] = "Error loading services"
m := newManager(f)
err := m.Apply(sample())
if err == nil || !strings.Contains(err.Error(), "Error loading services") {
t.Fatalf("expected testparm error, got %v", err)
}
if f.has("smbcontrol") || f.has("systemctl enable --now smbd") {
t.Errorf("smbd touched after failed testparm: %v", f.calls)
}
restored := false
for _, c := range f.calls {
if strings.HasPrefix(c, "sh -c") && strings.Contains(c, ".bak") && strings.Contains(c, "mv -f") && strings.Contains(c, DefaultSMBConf) && !strings.Contains(c, "cat >") {
restored = true
}
}
if !restored {
t.Errorf("backup not restored: %v", f.calls)
}
// NFS is still applied independently
if !f.has("exportfs -ra") {
t.Errorf("NFS skipped after SMB failure")
}
}
func TestApplyDisablesWhenEmpty(t *testing.T) {
f := newFake()
m := newManager(f)
c := Defaults() // no shares, no exports
if err := m.Apply(c); err != nil {
t.Fatal(err)
}
if !f.has("systemctl disable --now smbd nmbd") || !f.has("systemctl disable --now nfs-server") {
t.Errorf("services not disabled: %v", f.calls)
}
if f.has("smbcontrol") || f.has("exportfs") {
t.Errorf("reload attempted with nothing to serve: %v", f.calls)
}
}
func TestApplyNotInstalled(t *testing.T) {
f := newFake()
f.lookup = map[string]bool{}
m := newManager(f)
if err := m.Apply(sample()); err != nil || len(f.calls) != 0 {
t.Errorf("nothing should run without samba/nfs: %v %v", err, f.calls)
}
}
func TestUsers(t *testing.T) {
f := newFake()
f.reply["pdbedit -L"] = "alice:1001:\nbob:1002:Bob B\n"
f.fail["getent passwd carol"] = ""
f.reply["getent passwd bob"] = "bob:x:1002:1000::/nonexistent:/usr/sbin/nologin"
m := newManager(f)
users, err := m.ListUsers()
if err != nil || strings.Join(users, ",") != "alice,bob" {
t.Fatalf("list: %v %v", users, err)
}
if err := m.SetPassword("carol", "s3cret"); err != nil {
t.Fatal(err)
}
if !f.has("useradd -M -N -g 1000 -s /usr/sbin/nologin") {
t.Errorf("unix user not created: %v", f.calls)
}
if in := f.stdin["smbpasswd -s -a carol"]; in != "s3cret\ns3cret\n" {
t.Errorf("smbpasswd stdin = %q", in)
}
if err := m.SetPassword("Carol", "x"); err == nil {
t.Errorf("bad user name accepted")
}
if err := m.SetPassword("carol", "ab"); err == nil {
t.Errorf("short password accepted")
}
if err := m.DeleteUser("bob"); err != nil {
t.Fatal(err)
}
if !f.has("smbpasswd -x bob") || !f.has("userdel bob") {
t.Errorf("delete: %v", f.calls)
}
// the data owner keeps the unix account
f.reply["getent passwd ansible"] = "ansible:x:1000:1000::/home/ansible:/usr/sbin/nologin"
_ = m.DeleteUser("ansible")
if f.has("userdel ansible") {
t.Errorf("owner account deleted")
}
}