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) } }