package stacks import ( "log" "os" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) func newMailManager(t *testing.T, globalOn bool, fromName string) *Manager { t.Helper() lg := log.New(os.Stderr, "", 0) cfg := &config.Config{} cfg.MailRelay.ShimHost = "felhom-controller" cfg.MailRelay.FromDomains = []string{"felhom.eu"} m := &Manager{cfg: cfg, logger: lg, stacks: map[string]*Stack{}} sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg) if err != nil { t.Fatal(err) } if globalOn { if err := sett.SetAppEmail(true, fromName); err != nil { t.Fatal(err) } } m.settings = sett return m } func vaultwardenMeta() *Metadata { return &Metadata{ Slug: "vaultwarden", DisplayName: "Vaultwarden", SMTPMapping: &SMTPMapping{ HostVar: "SMTP_HOST", PortVar: "SMTP_PORT", SecurityVar: "SMTP_SECURITY", SecurityValue: "starttls", FromVar: "SMTP_FROM", FromNameVar: "SMTP_FROM_NAME", Extra: map[string]string{ "SMTP_ACCEPT_INVALID_CERTS": "true", "SMTP_ACCEPT_INVALID_HOSTNAMES": "true", }, }, } } func envMap(kvs []string) map[string]string { m := make(map[string]string, len(kvs)) for _, kv := range kvs { if i := strings.IndexByte(kv, '='); i >= 0 { m[kv[:i]] = kv[i+1:] } } return m } // §7 A / §8: global ON + app ON + mapping → full mapped env injected. func TestSMTPEnv_BothTogglesOn_Injects(t *testing.T) { m := newMailManager(t, true, "") got := envMap(m.smtpEnv(vaultwardenMeta(), true)) want := map[string]string{ "SMTP_HOST": "felhom-controller", "SMTP_PORT": "2525", "SMTP_SECURITY": "starttls", "SMTP_FROM": "vaultwarden@felhom.eu", "SMTP_FROM_NAME": "Vaultwarden", "SMTP_ACCEPT_INVALID_CERTS": "true", "SMTP_ACCEPT_INVALID_HOSTNAMES": "true", } for k, v := range want { if got[k] != v { t.Errorf("env %s = %q, want %q", k, got[k], v) } } } // §7 E / §8: per-app OFF → nothing injected even when global is ON. func TestSMTPEnv_PerAppOff_NoInjection(t *testing.T) { m := newMailManager(t, true, "") if got := m.smtpEnv(vaultwardenMeta(), false); got != nil { t.Fatalf("per-app off must inject nothing, got %v", got) } } // §7 E / §8: global OFF → nothing injected even when the app toggle is ON. func TestSMTPEnv_GlobalOff_NoInjection(t *testing.T) { m := newMailManager(t, false, "") if got := m.smtpEnv(vaultwardenMeta(), true); got != nil { t.Fatalf("global off must inject nothing, got %v", got) } } // §8: app with no smtp_mapping → nothing injected. func TestSMTPEnv_NoMapping_NoInjection(t *testing.T) { m := newMailManager(t, true, "") meta := &Metadata{Slug: "plex", DisplayName: "Plex"} if got := m.smtpEnv(meta, true); got != nil { t.Fatalf("app without smtp_mapping must inject nothing, got %v", got) } } // Household display-name override wins over the app display name. func TestSMTPEnv_HouseholdFromNameWins(t *testing.T) { m := newMailManager(t, true, "Kovács család") got := envMap(m.smtpEnv(vaultwardenMeta(), true)) if got["SMTP_FROM_NAME"] != "Kovács család" { t.Fatalf("SMTP_FROM_NAME = %q, want household name", got["SMTP_FROM_NAME"]) } } // Mealie-style mapping: different env keys + from_local + STARTTLS expressed as "TLS". func TestSMTPEnv_MealieMapping(t *testing.T) { m := newMailManager(t, true, "") meta := &Metadata{ Slug: "mealie", DisplayName: "Mealie", SMTPMapping: &SMTPMapping{ HostVar: "SMTP_HOST", PortVar: "SMTP_PORT", SecurityVar: "SMTP_AUTH_STRATEGY", SecurityValue: "TLS", FromVar: "SMTP_FROM_EMAIL", FromNameVar: "SMTP_FROM_NAME", FromLocal: "mealie", }, } got := envMap(m.smtpEnv(meta, true)) if got["SMTP_AUTH_STRATEGY"] != "TLS" { t.Errorf("SMTP_AUTH_STRATEGY = %q, want TLS", got["SMTP_AUTH_STRATEGY"]) } if got["SMTP_FROM_EMAIL"] != "mealie@felhom.eu" { t.Errorf("SMTP_FROM_EMAIL = %q, want mealie@felhom.eu", got["SMTP_FROM_EMAIL"]) } if got["SMTP_PORT"] != "2525" { t.Errorf("SMTP_PORT = %q, want 2525", got["SMTP_PORT"]) } } // §10: port chosen by tls_mode. plaintext→2526, starttls/empty→2525, implicit-tls→2465. func TestSMTPEnv_PortByTLSMode(t *testing.T) { m := newMailManager(t, true, "") cases := map[string]string{"": "2525", "starttls": "2525", "plaintext": "2526", "implicit-tls": "2465"} for mode, wantPort := range cases { meta := vaultwardenMeta() meta.SMTPMapping.TLSMode = mode got := envMap(m.smtpEnv(meta, true)) if got["SMTP_PORT"] != wantPort { t.Errorf("tls_mode=%q → SMTP_PORT=%q, want %q", mode, got["SMTP_PORT"], wantPort) } } } // §10 companion: shimPortForTLSMode must actually branch on the mode (not always 2525). func TestShimPortForTLSMode(t *testing.T) { if shimPortForTLSMode("plaintext") == shimPortForTLSMode("starttls") { t.Fatal("companion: plaintext and starttls must map to DIFFERENT ports (gap-1 fix)") } if shimPortForTLSMode("plaintext") != "2526" { t.Fatalf("plaintext must be 2526, got %q", shimPortForTLSMode("plaintext")) } } // §10: split-From. from_domain_var set → two keys (local + domain); unset → single @. func TestSMTPEnv_SplitFrom(t *testing.T) { m := newMailManager(t, true, "") meta := &Metadata{ Slug: "nextcloud", DisplayName: "Nextcloud", SMTPMapping: &SMTPMapping{ HostVar: "SMTP_HOST", PortVar: "SMTP_PORT", SecurityVar: "SMTP_SECURE", SecurityValue: "", FromVar: "MAIL_FROM_ADDRESS", FromDomainVar: "MAIL_DOMAIN", FromLocal: "nextcloud", TLSMode: "plaintext", }, } got := envMap(m.smtpEnv(meta, true)) if got["MAIL_FROM_ADDRESS"] != "nextcloud" { t.Errorf("split From local = %q, want bare 'nextcloud'", got["MAIL_FROM_ADDRESS"]) } if got["MAIL_DOMAIN"] != "felhom.eu" { t.Errorf("split From domain = %q, want 'felhom.eu'", got["MAIL_DOMAIN"]) } if got["SMTP_PORT"] != "2526" { t.Errorf("nextcloud SMTP_PORT = %q, want 2526 (plaintext)", got["SMTP_PORT"]) } // Companion: a mapping WITHOUT from_domain_var must produce the single full address (not split). single := vaultwardenMeta() gotS := envMap(m.smtpEnv(single, true)) if gotS["SMTP_FROM"] != "vaultwarden@felhom.eu" { t.Errorf("single-From mapping = %q, want 'vaultwarden@felhom.eu'", gotS["SMTP_FROM"]) } } func TestMetadata_SMTPMappingParse(t *testing.T) { dir := t.TempDir() yml := `display_name: Vaultwarden slug: vaultwarden smtp_mapping: host_var: SMTP_HOST port_var: SMTP_PORT security_var: SMTP_SECURITY security_value: starttls from_var: SMTP_FROM from_name_var: SMTP_FROM_NAME extra: SMTP_ACCEPT_INVALID_CERTS: "true" ` if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(yml), 0644); err != nil { t.Fatal(err) } meta := LoadMetadata(dir) if !meta.HasSMTPMapping() { t.Fatal("expected smtp_mapping to parse") } if meta.SMTPMapping.SecurityValue != "starttls" { t.Errorf("security_value = %q", meta.SMTPMapping.SecurityValue) } if meta.SMTPMapping.Extra["SMTP_ACCEPT_INVALID_CERTS"] != "true" { t.Errorf("extra not parsed: %v", meta.SMTPMapping.Extra) } } // Tolerant decode: an app with no smtp_mapping parses fine and reports no mapping. func TestMetadata_NoSMTPMapping(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte("display_name: Plex\nslug: plex\n"), 0644); err != nil { t.Fatal(err) } meta := LoadMetadata(dir) if meta.HasSMTPMapping() { t.Fatal("plex must report no smtp_mapping") } }