feat(samba): settings registry + smb.conf/compose renderers (R-7 slice 1, Part 1)
SMBSettings + SMBShare registry in settings (password never stored — only UserSet); NetBIOS-safe name validation. Pure infra renderers: RenderSambaConfig (hardened global block: SMB2 floor, bind interfaces only=lo eth0, disable netbios=no, force user block) + RenderSambaCompose (network_mode host, pinned image, :ro bind for read-only shares, passdb volume). Exact smb.conf golden + CRUD/validation tests.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newSMBTestSettings(t *testing.T) (*Settings, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
s, err := Load(path, log.New(os.Stderr, "", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
return s, path
|
||||
}
|
||||
|
||||
func TestValidateSMBServerName(t *testing.T) {
|
||||
good := []string{"FELHOM", "Otthon-1", "box_2", "A", "sixteencharnam"} // last = 14 chars
|
||||
for _, n := range good {
|
||||
if err := ValidateSMBServerName(n); err != nil {
|
||||
t.Errorf("%q should be valid: %v", n, err)
|
||||
}
|
||||
}
|
||||
bad := []string{"", " ", "sixteencharname1", "has space", "bad/slash", "-lead", "trail-", "dot.name", "back\\slash"}
|
||||
for _, n := range bad {
|
||||
if err := ValidateSMBServerName(n); err == nil {
|
||||
t.Errorf("%q should be invalid", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSMBShareName(t *testing.T) {
|
||||
good := []string{"dokumentumok", "filmek", "csalad_kepek", "A1"}
|
||||
for _, n := range good {
|
||||
if err := ValidateSMBShareName(n); err != nil {
|
||||
t.Errorf("%q should be valid: %v", n, err)
|
||||
}
|
||||
}
|
||||
// Scenario C: "../x", >15-char, and invalid NetBIOS charset must all refuse.
|
||||
bad := []string{"", "../x", "a/b", "a\\b", "file.txt", "way-too-long-nam", "has space", "-x", "x-"}
|
||||
for _, n := range bad {
|
||||
if err := ValidateSMBShareName(n); err == nil {
|
||||
t.Errorf("%q should be invalid", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMBShareCRUD(t *testing.T) {
|
||||
s, path := newSMBTestSettings(t)
|
||||
|
||||
sh := SMBShare{Name: "dokumentumok", Path: "/mnt/felhom-drives/scratch1/shares/dokumentumok", Offsite: true}
|
||||
if err := s.AddSMBShare(sh); err != nil {
|
||||
t.Fatalf("AddSMBShare: %v", err)
|
||||
}
|
||||
got := s.GetSMBShares()
|
||||
if len(got) != 1 || got[0].Name != "dokumentumok" || !got[0].Offsite {
|
||||
t.Fatalf("share not stored as expected: %+v", got)
|
||||
}
|
||||
if got[0].CreatedAt == "" {
|
||||
t.Error("CreatedAt not stamped on add")
|
||||
}
|
||||
|
||||
// Case-insensitive collision is refused AND does not mutate the registry (Scenario C non-effect).
|
||||
if err := s.AddSMBShare(SMBShare{Name: "DOKUMENTUMOK", Path: "/other"}); err == nil {
|
||||
t.Error("case-insensitive name collision should be refused")
|
||||
}
|
||||
if len(s.GetSMBShares()) != 1 {
|
||||
t.Error("refused collision must not mutate the registry")
|
||||
}
|
||||
|
||||
// Offsite (Felhőmentés) toggle persists.
|
||||
if err := s.SetSMBShareOffsite("dokumentumok", false); err != nil {
|
||||
t.Fatalf("SetSMBShareOffsite: %v", err)
|
||||
}
|
||||
if s.GetSMBShares()[0].Offsite {
|
||||
t.Error("offsite toggle did not persist")
|
||||
}
|
||||
|
||||
// Persistence across reload.
|
||||
s2, err := Load(path, log.New(os.Stderr, "", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if r := s2.GetSMBShares(); len(r) != 1 || r[0].Name != "dokumentumok" {
|
||||
t.Errorf("share did not persist across reload: %+v", r)
|
||||
}
|
||||
|
||||
// Remove is config-only + errors on a missing name.
|
||||
if err := s.RemoveSMBShare("dokumentumok"); err != nil {
|
||||
t.Fatalf("RemoveSMBShare: %v", err)
|
||||
}
|
||||
if len(s.GetSMBShares()) != 0 {
|
||||
t.Error("share not removed")
|
||||
}
|
||||
if err := s.RemoveSMBShare("nope"); err == nil {
|
||||
t.Error("removing a missing share should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMBSettingsDefaultsAndNoPassword(t *testing.T) {
|
||||
s, path := newSMBTestSettings(t)
|
||||
|
||||
// Unconfigured → disabled with the default server name (never nil).
|
||||
def := s.GetSMBSettings()
|
||||
if def.Enabled || def.ServerName != DefaultSMBServerName {
|
||||
t.Errorf("default SMB settings wrong: %+v", def)
|
||||
}
|
||||
|
||||
if err := s.SetSMBEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetSMBServerName("OTTHON"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetSMBUserSet(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cur := s.GetSMBSettings()
|
||||
if !cur.Enabled || cur.ServerName != "OTTHON" || !cur.UserSet {
|
||||
t.Errorf("SMB settings not stored: %+v", cur)
|
||||
}
|
||||
|
||||
// The household SMB password must NEVER be persisted (rule 4). The struct has no field for it;
|
||||
// prove the on-disk file carries only the UserSet flag, no plaintext secret.
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "password") && strings.Contains(string(raw), "smb") {
|
||||
// there is a claim_code_hash etc.; assert no *smb* password key specifically
|
||||
if strings.Contains(string(raw), "smb_password") || strings.Contains(string(raw), "\"password\": \"") {
|
||||
t.Errorf("settings.json appears to store an SMB password:\n%s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user