package web import ( "context" "errors" "net/http" "net/url" "strings" "testing" "nasmon/internal/shares" ) // shareRunner fakes the host runner for the shares manager. type shareRunner struct { calls []string stdin map[string]string fail string users string } func (f *shareRunner) run(_ context.Context, stdin, name string, args ...string) (string, error) { c := name + " " + strings.Join(args, " ") f.calls = append(f.calls, c) if stdin != "" { if f.stdin == nil { f.stdin = map[string]string{} } f.stdin[c] = stdin } if f.fail != "" && strings.HasPrefix(c, f.fail) { return "boom: " + f.fail, errors.New("boom: " + f.fail) } if strings.HasPrefix(c, "pdbedit -L") { return f.users, nil } return "", nil } func (f *shareRunner) has(prefix string) bool { for _, c := range f.calls { if strings.HasPrefix(c, prefix) { return true } } return false } func (f *shareRunner) written(path string) string { for k, v := range f.stdin { if strings.Contains(k, path) { return v } } return "" } func withShareRunner(srv *Server, f *shareRunner) { srv.shm = shares.New(f.run) srv.shm.LookPath = func(string) (string, error) { return "/usr/sbin/x", nil } srv.shm.Owner, srv.shm.UID, srv.shm.GID = "ansible", "1000", "1000" } func TestSharesSMBFlow(t *testing.T) { srv, _, _ := newTestServer(t) f := &shareRunner{users: "alice:1001:\n"} withShareRunner(srv, f) // page lists users and mountpoints, prefills from ?path= body := get(srv, "/shares?path=/tank/data").Body.String() for _, want := range []string{`value="/tank/data"`, `value="data"`, `value="alice"`, "No SMB shares yet"} { if !strings.Contains(body, want) { t.Errorf("shares page missing %q", want) } } // create a guest share (trailing slash is trimmed) rec := postForm(srv, "/shares/smb/save", url.Values{"name": {"data"}, "path": {"/tank/data/"}, "comment": {"Stuff"}, "guest": {"1"}, "browseable": {"1"}, "hide_sys": {"1"}}) if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "Share+data+saved") { t.Fatalf("create: %d %s %s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) } c := srv.shares.Get() if len(c.SMB.Shares) != 1 || c.SMB.Shares[0].Path != "/tank/data" || !c.SMB.Shares[0].Guest { t.Fatalf("saved config: %+v", c.SMB.Shares) } if !f.has("testparm -s") || !f.has("systemctl enable --now smbd nmbd") || !f.has("smbcontrol all reload-config") { t.Errorf("apply calls: %v", f.calls) } conf := f.written(shares.DefaultSMBConf) for _, want := range []string{"[data]", "path = /tank/data", "guest ok = yes", "force user = ansible", "veto files"} { if !strings.Contains(conf, want) { t.Errorf("smb.conf missing %q:\n%s", want, conf) } } if !strings.Contains(get(srv, "/shares").Body.String(), "data") { t.Errorf("share not listed") } // refused: duplicate name, path outside pools, non-guest without users, reserved name, unclean path for _, form := range []url.Values{ {"name": {"data"}, "path": {"/tank/data"}, "guest": {"1"}}, {"name": {"etc"}, "path": {"/etc"}, "guest": {"1"}}, {"name": {"priv"}, "path": {"/tank/data"}}, {"name": {"global"}, "path": {"/tank/data"}, "guest": {"1"}}, {"name": {"gone"}, "path": {"/tank/nope/.."}, "guest": {"1"}}, } { rec = postForm(srv, "/shares/smb/save", form) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not saved") { t.Errorf("form %v accepted: %d", form, rec.Code) } } if len(srv.shares.Get().SMB.Shares) != 1 { t.Fatalf("invalid share stored") } // edit: rename + restrict to alice rec = postForm(srv, "/shares/smb/save", url.Values{"orig": {"data"}, "name": {"private"}, "path": {"/tank/data"}, "users": {"alice"}, "read_only": {"1"}, "browseable": {"1"}}) if rec.Code != http.StatusSeeOther { t.Fatalf("edit: %d %s", rec.Code, rec.Body.String()) } c = srv.shares.Get() if len(c.SMB.Shares) != 1 || c.SMB.Shares[0].Name != "private" || c.SMB.Shares[0].Guest || strings.Join(c.SMB.Shares[0].Users, ",") != "alice" || !c.SMB.Shares[0].ReadOnly { t.Fatalf("edited share: %+v", c.SMB.Shares) } if !strings.Contains(f.written(shares.DefaultSMBConf), "valid users = alice") { t.Errorf("smb.conf not regenerated after edit") } // a user still referenced by a share cannot be deleted rec = postForm(srv, "/shares/users/delete", url.Values{"name": {"alice"}}) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "still allowed on share private") || f.has("smbpasswd -x") { t.Errorf("user deletion while in use: %d", rec.Code) } // add a user: password goes to smbpasswd on stdin rec = postForm(srv, "/shares/users/save", url.Values{"name": {"bob"}, "password": {"hunter2"}}) if rec.Code != http.StatusSeeOther || f.stdin["smbpasswd -s -a bob"] != "hunter2\nhunter2\n" { t.Errorf("add user: %d %v", rec.Code, f.stdin) } // delete the last share: smbd stopped rec = postForm(srv, "/shares/smb/delete", url.Values{"name": {"private"}}) if rec.Code != http.StatusSeeOther || len(srv.shares.Get().SMB.Shares) != 0 { t.Errorf("delete: %d", rec.Code) } if !f.has("systemctl disable --now smbd nmbd") { t.Errorf("smbd not stopped after last share removed: %v", f.calls) } } func TestSharesApplyFailureIsReported(t *testing.T) { srv, _, _ := newTestServer(t) f := &shareRunner{fail: "testparm"} withShareRunner(srv, f) rec := postForm(srv, "/shares/smb/save", url.Values{"name": {"data"}, "path": {"/tank/data"}, "guest": {"1"}, "browseable": {"1"}}) loc := rec.Header().Get("Location") if rec.Code != http.StatusSeeOther || !strings.Contains(loc, "applying+failed") { t.Fatalf("expected redirect with failure notice: %d %s", rec.Code, loc) } if len(srv.shares.Get().SMB.Shares) != 1 { t.Errorf("config not saved") } body := get(srv, "/shares").Body.String() if !strings.Contains(body, "not applied") || !strings.Contains(body, "/shares/apply") { t.Errorf("pending state not shown") } // fix the runner and re-apply f.fail = "" rec = postForm(srv, "/shares/apply", nil) if rec.Code != http.StatusSeeOther || strings.Contains(rec.Header().Get("Location"), "failed") { t.Errorf("re-apply: %d %s", rec.Code, rec.Header().Get("Location")) } if strings.Contains(get(srv, "/shares").Body.String(), "not applied") { t.Errorf("pending state still shown") } } func TestSharesNFSFlow(t *testing.T) { srv, _, _ := newTestServer(t) f := &shareRunner{} withShareRunner(srv, f) rec := postForm(srv, "/shares/nfs/save", url.Values{"path": {"/tank/data"}, "clients": {"192.168.101.0/24, pc.lan"}, "squash": {"all"}, "async": {"1"}}) if rec.Code != http.StatusSeeOther { t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) } exp := f.written(shares.DefaultExports) if !strings.Contains(exp, `"/tank/data" 192.168.101.0/24(rw,async,all_squash,anonuid=1000,anongid=1000,crossmnt,no_subtree_check) pc.lan(`) { t.Errorf("exports:\n%s", exp) } if !f.has("systemctl enable --now nfs-server") || !f.has("exportfs -ra") { t.Errorf("nfs apply calls: %v", f.calls) } for _, form := range []url.Values{ {"path": {"/tank/data"}, "clients": {"*"}, "squash": {"root"}}, // duplicate {"path": {"/tank/data2"}, "clients": {""}, "squash": {"root"}}, // no clients {"path": {"/tank/data2"}, "clients": {"10.0.0.0/33"}, "squash": {"root"}}, // bad cidr {"path": {"/tank/data2"}, "clients": {"*"}, "squash": {"weird"}}, // bad squash } { if rec := postForm(srv, "/shares/nfs/save", form); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not saved") { t.Errorf("form %v accepted", form) } } body := get(srv, "/shares").Body.String() if !strings.Contains(body, "192.168.101.0/24, pc.lan") || !strings.Contains(body, ":/tank/data") { t.Errorf("export not listed") } body = get(srv, "/shares?nedit=/tank/data").Body.String() if !strings.Contains(body, "Edit export /tank/data") || !strings.Contains(body, `value="192.168.101.0/24, pc.lan"`) { t.Errorf("edit form not prefilled") } rec = postForm(srv, "/shares/nfs/delete", url.Values{"path": {"/tank/data"}}) if rec.Code != http.StatusSeeOther || len(srv.shares.Get().NFS.Exports) != 0 || !f.has("systemctl disable --now nfs-server") { t.Errorf("delete: %d %v", rec.Code, f.calls) } }