build / build (push) Successful in 1m43s
- include SMB drop-ins from /etc/samba/smb.conf.d into the generated smb.conf - keep smbd/nfs-server running while other services (/etc/exports.d, drop-ins) share, instead of disabling them when nasmon has no shares of its own - list those shares read-only on /shares - settings: RepoISO URL; poll its /api/integration/status, dashboard card, menu link, alerts for portal down, stale worker, failed jobs, unmounted publications Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
10 KiB
Go
251 lines
10 KiB
Go
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"
|
|
srv.shm.IncludeDir, srv.shm.ExportsDir = "", ""
|
|
}
|
|
|
|
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", `<option value="/mnt/usb">`} {
|
|
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(), "<b>data</b>") {
|
|
t.Errorf("share not listed")
|
|
}
|
|
|
|
// refused: duplicate name, system dirs, symlink into a system dir, missing dir,
|
|
// 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": {"logs"}, "path": {"/var/log"}, "guest": {"1"}},
|
|
{"name": {"evil"}, "path": {"/srv/evil"}, "guest": {"1"}},
|
|
{"name": {"nowhere"}, "path": {"/opt/nowhere"}, "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 plain folder outside any ZFS dataset
|
|
rec = postForm(srv, "/shares/smb/save", url.Values{"name": {"media"}, "path": {"/srv/media"}, "guest": {"1"}, "browseable": {"1"}})
|
|
if rec.Code != http.StatusSeeOther || !strings.Contains(f.written(shares.DefaultSMBConf), "path = /srv/media") {
|
|
t.Fatalf("non-ZFS share: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
postForm(srv, "/shares/smb/delete", url.Values{"name": {"media"}})
|
|
// 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
|
|
{"path": {"/etc"}, "clients": {"*"}, "squash": {"root"}}, // system dir
|
|
} {
|
|
if rec := postForm(srv, "/shares/nfs/save", form); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Not saved") {
|
|
t.Errorf("form %v accepted", form)
|
|
}
|
|
}
|
|
if rec := postForm(srv, "/shares/nfs/save", url.Values{"path": {"/srv/media"}, "clients": {"*"}, "squash": {"root"}}); rec.Code != http.StatusSeeOther ||
|
|
!strings.Contains(f.written(shares.DefaultExports), `"/srv/media" *(`) {
|
|
t.Fatalf("non-ZFS export: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
postForm(srv, "/shares/nfs/delete", url.Values{"path": {"/srv/media"}})
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestSharesSMBLegacySetting(t *testing.T) {
|
|
srv, _, _ := newTestServer(t)
|
|
f := &shareRunner{users: "alice:1001:\n"}
|
|
withShareRunner(srv, f)
|
|
postForm(srv, "/shares/smb/save", url.Values{"name": {"iso"}, "path": {"/tank/data"}, "users": {"alice"}, "browseable": {"1"}})
|
|
rec := postForm(srv, "/shares/smb/settings", url.Values{"enabled": {"1"}, "workgroup": {"workgroup"}, "server_string": {"NAS"}, "legacy": {"1"}})
|
|
if rec.Code != http.StatusSeeOther || !srv.shares.Get().SMB.Legacy {
|
|
t.Fatalf("legacy not saved: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if conf := f.written(shares.DefaultSMBConf); !strings.Contains(conf, "server min protocol = NT1") {
|
|
t.Errorf("smb.conf without SMB1:\n%s", conf)
|
|
}
|
|
if !strings.Contains(get(srv, "/shares").Body.String(), `name="legacy" value="1" checked`) {
|
|
t.Errorf("legacy checkbox not shown as checked")
|
|
}
|
|
postForm(srv, "/shares/smb/settings", url.Values{"enabled": {"1"}, "workgroup": {"WORKGROUP"}, "server_string": {"NAS"}})
|
|
if srv.shares.Get().SMB.Legacy || !strings.Contains(f.written(shares.DefaultSMBConf), "server min protocol = SMB2") {
|
|
t.Errorf("legacy not cleared")
|
|
}
|
|
}
|