0dcbea90b2
ensureSamba joins EnsureBaseStack after filebrowser, gated on SMB.Enabled (cloudflared conditional precedent); reconcile is idempotent (unchanged config + running container = ZERO compose calls, asserted via seam). Atomic tmp+fsync+ rename config writes. Password applied via smbpasswd on STDIN (never argv/log/ settings). Disable = compose down, volumes + folders KEPT. samba added to IsProtectedStack in code (controller.yaml is golden-generated and predates it), which also makes the app-backup loops correctly skip it.
299 lines
9.0 KiB
Go
299 lines
9.0 KiB
Go
package stacks
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// newSambaManager builds a Manager whose samba lifecycle runs for real (render → atomic write →
|
|
// change detection) with ONLY the docker touchpoints seamed out: compose up, smbpasswd, liveness.
|
|
func newSambaManager(t *testing.T) (*Manager, *settings.Settings, string, *int) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
cfg := &config.Config{}
|
|
cfg.Paths.StacksDir = filepath.Join(root, "stacks")
|
|
|
|
sett, err := settings.Load(filepath.Join(root, "settings.json"), log.New(io.Discard, "", 0))
|
|
if err != nil {
|
|
t.Fatalf("settings.Load: %v", err)
|
|
}
|
|
upCalls := 0
|
|
m := &Manager{
|
|
cfg: cfg,
|
|
logger: log.New(io.Discard, "", 0),
|
|
stacks: map[string]*Stack{},
|
|
settings: sett,
|
|
sambaUpFn: func(string) error { upCalls++; return nil },
|
|
sambaRunFn: func() bool { return false },
|
|
}
|
|
return m, sett, root, &upCalls
|
|
}
|
|
|
|
// seedShare registers a storage root and creates a share folder with a file inside it.
|
|
func seedShare(t *testing.T, sett *settings.Settings, root, name string) string {
|
|
t.Helper()
|
|
storageRoot := filepath.Join(root, "drive")
|
|
shareDir := filepath.Join(storageRoot, "shares", name)
|
|
if err := os.MkdirAll(shareDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(shareDir, "csalad.txt"), []byte("fontos adat"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.AddStoragePath(settings.StoragePath{
|
|
Path: storageRoot, Label: "teszt", Schedulable: true,
|
|
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return shareDir
|
|
}
|
|
|
|
// snapshotTree hashes every file under dir so a test can prove NOTHING changed.
|
|
func snapshotTree(t *testing.T, dir string) map[string]string {
|
|
t.Helper()
|
|
out := map[string]string{}
|
|
err := filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if fi.IsDir() {
|
|
return nil
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sum := sha256.Sum256(b)
|
|
rel, _ := filepath.Rel(dir, p)
|
|
out[rel] = hex.EncodeToString(sum[:])
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func enableSMB(t *testing.T, sett *settings.Settings) {
|
|
t.Helper()
|
|
if err := sett.SetSMBEnabled(true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.SetSMBUserSet(true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// Scenario A: enable + first share → smb.conf carries exactly that share, compose written, one up.
|
|
func TestSambaReconcile_HappyPath(t *testing.T) {
|
|
m, sett, root, upCalls := newSambaManager(t)
|
|
shareDir := seedShare(t, sett, root, "dokumentumok")
|
|
enableSMB(t, sett)
|
|
if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir, Offsite: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := m.ReconcileSamba(); err != nil {
|
|
t.Fatalf("ReconcileSamba: %v", err)
|
|
}
|
|
conf, err := os.ReadFile(filepath.Join(m.sambaDir(), "smb.conf"))
|
|
if err != nil {
|
|
t.Fatalf("smb.conf not written: %v", err)
|
|
}
|
|
if !strings.Contains(string(conf), "[dokumentumok]") {
|
|
t.Errorf("share section missing:\n%s", conf)
|
|
}
|
|
if !strings.Contains(string(conf), "path = "+shareDir) {
|
|
t.Errorf("share path missing:\n%s", conf)
|
|
}
|
|
if !strings.Contains(string(conf), "force user = felhom") {
|
|
t.Error("force user block missing")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(m.sambaDir(), "docker-compose.yml")); err != nil {
|
|
t.Errorf("compose not written: %v", err)
|
|
}
|
|
if *upCalls != 1 {
|
|
t.Errorf("expected exactly 1 compose up, got %d", *upCalls)
|
|
}
|
|
}
|
|
|
|
// §10 idempotency: re-running with an UNCHANGED registry while the container runs performs ZERO
|
|
// compose calls (the fake seam is the assertion).
|
|
func TestSambaReconcile_IdempotentNoComposeCall(t *testing.T) {
|
|
m, sett, root, upCalls := newSambaManager(t)
|
|
shareDir := seedShare(t, sett, root, "dokumentumok")
|
|
enableSMB(t, sett)
|
|
if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir, Offsite: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.ReconcileSamba(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if *upCalls != 1 {
|
|
t.Fatalf("setup: expected 1 up, got %d", *upCalls)
|
|
}
|
|
// Container now running + config unchanged → the re-run must be a pure no-op.
|
|
m.sambaRunFn = func() bool { return true }
|
|
before := *upCalls
|
|
if err := m.ReconcileSamba(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if *upCalls != before {
|
|
t.Errorf("unchanged registry must perform NO compose call: calls went %d → %d", before, *upCalls)
|
|
}
|
|
// A real change must still apply.
|
|
if err := sett.SetSMBShareOffsite("dokumentumok", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.RemoveSMBShare("dokumentumok"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.ReconcileSamba(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if *upCalls != before+1 {
|
|
t.Errorf("a changed registry must trigger exactly one compose up, got %d", *upCalls-before)
|
|
}
|
|
}
|
|
|
|
// Edge case: a share on a DISCONNECTED storage path is never exported (config retained).
|
|
func TestSambaReconcile_DeadMountOmitted(t *testing.T) {
|
|
m, sett, root, _ := newSambaManager(t)
|
|
shareDir := seedShare(t, sett, root, "filmek")
|
|
enableSMB(t, sett)
|
|
if err := sett.AddSMBShare(settings.SMBShare{Name: "filmek", Path: shareDir, Offsite: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.SetDisconnected(filepath.Join(root, "drive"), true, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.ReconcileSamba(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conf, err := os.ReadFile(filepath.Join(m.sambaDir(), "smb.conf"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(conf), "[filmek]") {
|
|
t.Errorf("a share on a disconnected drive must NOT be exported:\n%s", conf)
|
|
}
|
|
// The share config itself is retained (only the export is withheld).
|
|
if len(sett.GetSMBShares()) != 1 {
|
|
t.Error("share config must be retained while the drive is away")
|
|
}
|
|
}
|
|
|
|
// Scenario E: deleting a share and disabling sharing NEVER touch the folder or its contents.
|
|
func TestSambaShareDeleteAndDisableKeepData(t *testing.T) {
|
|
m, sett, root, _ := newSambaManager(t)
|
|
shareDir := seedShare(t, sett, root, "dokumentumok")
|
|
enableSMB(t, sett)
|
|
if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir, Offsite: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.ReconcileSamba(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := snapshotTree(t, shareDir)
|
|
|
|
// Delete the share → section gone, data identical.
|
|
if err := sett.RemoveSMBShare("dokumentumok"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.ReconcileSamba(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conf, _ := os.ReadFile(filepath.Join(m.sambaDir(), "smb.conf"))
|
|
if strings.Contains(string(conf), "[dokumentumok]") {
|
|
t.Error("deleted share must be absent from smb.conf")
|
|
}
|
|
if got := snapshotTree(t, shareDir); !sameTree(before, got) {
|
|
t.Errorf("share delete must not touch the folder:\nbefore=%v\nafter=%v", before, got)
|
|
}
|
|
|
|
// Disable sharing → data still identical.
|
|
if err := sett.SetSMBEnabled(false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.ReconcileSamba(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := snapshotTree(t, shareDir); !sameTree(before, got) {
|
|
t.Errorf("disabling sharing must not touch the folder:\nbefore=%v\nafter=%v", before, got)
|
|
}
|
|
}
|
|
|
|
func sameTree(a, b map[string]string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for k, v := range a {
|
|
if b[k] != v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Rule 4: the household SMB password reaches smbpasswd's stdin and NOTHING else — settings.json
|
|
// must carry only the UserSet boolean.
|
|
func TestSambaPasswordNeverPersisted(t *testing.T) {
|
|
m, sett, root, _ := newSambaManager(t)
|
|
enableSMB(t, sett)
|
|
if err := sett.SetSMBUserSet(false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
const secret = "TitkosJelszo123"
|
|
var seen string
|
|
m.sambaPasswdFn = func(pw string) error { seen = pw; return nil }
|
|
|
|
if err := m.SetSMBPassword(secret); err != nil {
|
|
t.Fatalf("SetSMBPassword: %v", err)
|
|
}
|
|
if seen != secret {
|
|
t.Errorf("password did not reach the smbpasswd seam: got %q", seen)
|
|
}
|
|
if !sett.GetSMBSettings().UserSet {
|
|
t.Error("UserSet must be recorded after a successful password apply")
|
|
}
|
|
|
|
// The secret must appear in NO persisted artefact: settings.json, smb.conf, or compose.
|
|
for _, p := range []string{
|
|
filepath.Join(root, "settings.json"),
|
|
filepath.Join(m.sambaDir(), "smb.conf"),
|
|
filepath.Join(m.sambaDir(), "docker-compose.yml"),
|
|
} {
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
continue // not all files exist in every path
|
|
}
|
|
if strings.Contains(string(b), secret) {
|
|
t.Errorf("SMB password leaked into %s", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A password apply is refused while the feature is off (no container should ever be started for it).
|
|
func TestSambaPasswordRefusedWhenDisabled(t *testing.T) {
|
|
m, _, _, upCalls := newSambaManager(t)
|
|
m.sambaPasswdFn = func(string) error { t.Fatal("smbpasswd must not run while disabled"); return nil }
|
|
if err := m.SetSMBPassword("x"); err == nil {
|
|
t.Error("setting a password while sharing is disabled must be refused")
|
|
}
|
|
if *upCalls != 0 {
|
|
t.Errorf("no compose call may happen while disabled, got %d", *upCalls)
|
|
}
|
|
}
|