veza/veza-backend-api/internal/email/smtp_env_test.go
senke 9002e91d91 refactor(backend,infra): unify SMTP env schema on canonical SMTP_* names
Third item of the v1.0.6 backlog. The v1.0.5.1 hotfix surfaced that two
email paths in-tree read *different* env vars for the same configuration:

    internal/email/sender.go         internal/services/email_service.go
    SMTP_USERNAME                    SMTP_USER
    SMTP_FROM                        FROM_EMAIL
    SMTP_FROM_NAME                   FROM_NAME

The hotfix worked around it by exporting both sets in `.env.template`.
This commit reconciles them onto a single schema so the workaround can
go away.

Changes
  * `internal/email/sender.go` is now the single loader. The canonical
    names (`SMTP_USERNAME`, `SMTP_FROM`, `SMTP_FROM_NAME`) are read
    first; the legacy names (`SMTP_USER`, `FROM_EMAIL`, `FROM_NAME`)
    stay supported as a migration fallback that logs a structured
    deprecation warning ("remove_in: v1.1.0"). Canonical always wins
    over deprecated — no silent precedence flip.
  * `NewSMTPEmailSender` callers keep working unchanged; a new
    `LoadSMTPConfigFromEnvWithLogger(*zap.Logger)` variant lets callers
    opt into the warning stream.
  * `internal/services/email_service.go` drops its six inline
    `os.Getenv` reads and delegates to the shared loader, so
    `AuthService.Register` and `RequestPasswordReset` now see exactly
    the same config as the async job worker.
  * `.env.template`: the duplicate (SMTP_USER + FROM_EMAIL + FROM_NAME)
    block added in v1.0.5.1 is removed — only the canonical SMTP_*
    names ship for new contributors.
  * `docker-compose.yml` (backend-api service): FROM_EMAIL / FROM_NAME
    renamed to SMTP_FROM / SMTP_FROM_NAME to match the canonical schema.
  * No Host/Port default injected in the loader. If SMTP_HOST is
    empty, callers see Host=="" and log-only (historic dev behavior).
    Dev defaults (MailHog localhost:1025) live in `.env.template`, so
    a fresh clone still works; a misconfigured prod pod fails loud
    instead of silently dialing localhost.

Tests
  * 5 new Go tests in `internal/email/smtp_env_test.go`: empty-env
    returns empty config; canonical names read directly; deprecated
    names fall back (one warning per var); canonical wins over
    deprecated silently; nil logger is allowed.
  * Existing `TestLoadSMTPConfigFromEnv`, `TestSMTPEmailSender_Send`,
    and every auth/services package remained green (40+ packages).

Import-cycle note: the loader deliberately lives in `internal/email`,
not `internal/config`, because `internal/config` already depends on
`internal/email` (wiring `EmailSender` at boot). Putting the loader in
`email` keeps the dependency flow one-way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 20:44:09 +02:00

103 lines
3.1 KiB
Go

package email
import (
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
)
// clearAllSMTPEnv zeroes every SMTP-related env var (canonical + deprecated)
// so each test starts from a known blank slate. t.Setenv guarantees restore.
func clearAllSMTPEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
"SMTP_HOST", "SMTP_PORT", "SMTP_PASSWORD",
"SMTP_USERNAME", "SMTP_FROM", "SMTP_FROM_NAME",
"SMTP_USER", "FROM_EMAIL", "FROM_NAME",
} {
t.Setenv(k, "")
}
}
func TestLoadSMTPConfig_EmptyWhenNothingSet(t *testing.T) {
clearAllSMTPEnv(t)
cfg := LoadSMTPConfigFromEnv()
assert.Empty(t, cfg.Host, "Host must stay empty so callers can log-only")
assert.Empty(t, cfg.Port)
assert.Empty(t, cfg.Username)
assert.Empty(t, cfg.From)
assert.Empty(t, cfg.FromName)
}
func TestLoadSMTPConfig_CanonicalNamesReadDirectly(t *testing.T) {
clearAllSMTPEnv(t)
t.Setenv("SMTP_HOST", "mailhog")
t.Setenv("SMTP_PORT", "1025")
t.Setenv("SMTP_USERNAME", "veza-api")
t.Setenv("SMTP_PASSWORD", "supersecret")
t.Setenv("SMTP_FROM", "no-reply@veza.fm")
t.Setenv("SMTP_FROM_NAME", "Veza")
cfg := LoadSMTPConfigFromEnv()
assert.Equal(t, "mailhog", cfg.Host)
assert.Equal(t, "1025", cfg.Port)
assert.Equal(t, "veza-api", cfg.Username)
assert.Equal(t, "supersecret", cfg.Password)
assert.Equal(t, "no-reply@veza.fm", cfg.From)
assert.Equal(t, "Veza", cfg.FromName)
}
func TestLoadSMTPConfig_DeprecatedNamesFallBack(t *testing.T) {
clearAllSMTPEnv(t)
// Only the pre-v1.0.6 env vars are set.
t.Setenv("SMTP_USER", "legacy-user")
t.Setenv("FROM_EMAIL", "legacy@veza.fm")
t.Setenv("FROM_NAME", "Legacy Veza")
core, observed := observer.New(zapcore.WarnLevel)
cfg := LoadSMTPConfigFromEnvWithLogger(zap.New(core))
assert.Equal(t, "legacy-user", cfg.Username, "fallback must pick up SMTP_USER when SMTP_USERNAME is empty")
assert.Equal(t, "legacy@veza.fm", cfg.From)
assert.Equal(t, "Legacy Veza", cfg.FromName)
entries := observed.All()
assert.Len(t, entries, 3, "one warning per deprecated var in use")
seen := map[string]bool{}
for _, e := range entries {
for _, f := range e.Context {
if f.Key == "deprecated" {
seen[f.String] = true
}
}
}
assert.True(t, seen["SMTP_USER"])
assert.True(t, seen["FROM_EMAIL"])
assert.True(t, seen["FROM_NAME"])
}
func TestLoadSMTPConfig_CanonicalWinsOverDeprecated(t *testing.T) {
clearAllSMTPEnv(t)
t.Setenv("SMTP_USERNAME", "canonical-user")
t.Setenv("SMTP_USER", "legacy-user-ignored")
t.Setenv("SMTP_FROM", "canonical@veza.fm")
t.Setenv("FROM_EMAIL", "legacy@veza.fm")
core, observed := observer.New(zapcore.WarnLevel)
cfg := LoadSMTPConfigFromEnvWithLogger(zap.New(core))
assert.Equal(t, "canonical-user", cfg.Username)
assert.Equal(t, "canonical@veza.fm", cfg.From)
assert.Equal(t, 0, observed.Len(),
"no warning should fire when canonical is set — deprecated vars are ignored silently")
}
func TestLoadSMTPConfig_NilLoggerIsAllowed(t *testing.T) {
clearAllSMTPEnv(t)
t.Setenv("SMTP_USER", "legacy")
assert.NotPanics(t, func() { _ = LoadSMTPConfigFromEnv() })
cfg := LoadSMTPConfigFromEnv()
assert.Equal(t, "legacy", cfg.Username)
}