// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT //go:build linux || freebsd package config import ( "fmt" "os" "path/filepath" "slices" "strings" "testing" "time" "sourcedock.dev/petrbalvin/nuntius/internal/contactform" ) func TestLoadWritesAndParsesDefault(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") // The default template references ${NUNTIUS_SMTP_PASSWORD}; expansion // is strict, so the variable must exist even though nothing sends mail. t.Setenv("NUNTIUS_SMTP_PASSWORD", "test-only") // The first Load writes the default template, then parses it back. cfg, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Server.Port != 8080 { t.Errorf("Server.Port = %d, want 8080", cfg.Server.Port) } if cfg.DataDir != "./data" { t.Errorf("DataDir = %q, want ./data", cfg.DataDir) } if len(cfg.Forms) != 3 { t.Fatalf("len(Forms) = %d, want 3", len(cfg.Forms)) } if cfg.Forms[0].Name != "contact" || cfg.Forms[0].SMTP.Host != "smtp.example.com" { t.Errorf("Forms[0] = %#v", cfg.Forms[0]) } if cfg.Forms[2].RateLimit() != 100 { t.Errorf("newsletter rate_limit_per_hour = %d, want 100", cfg.Forms[2].RateLimit()) } } // newsletterFixture returns a form that passes every check that runs // before the archive and auto-reply rejections. func newsletterFixture() Form { return Form{ Name: "news", Path: "/news", Type: "newsletter", To: "owner@example.com", SMTP: SMTPConfig{Host: "smtp.example.com", Port: 587, User: "news@example.com"}, } } func TestArchiveRejectedOnNewsletter(t *testing.T) { c := &Config{Forms: []Form{}} f := newsletterFixture() f.Archive = true c.Forms = append(c.Forms, f) err := c.validate() if err == nil || !strings.Contains(err.Error(), "archive") { t.Errorf("validate() = %v, want an archive rejection", err) } } func TestAutoReplyRejectedOnNewsletter(t *testing.T) { c := &Config{Forms: []Form{}} f := newsletterFixture() f.AutoReply = true c.Forms = append(c.Forms, f) err := c.validate() if err == nil || !strings.Contains(err.Error(), "auto_reply") { t.Errorf("validate() = %v, want an auto_reply rejection", err) } } func TestTelegramValidation(t *testing.T) { test := func(name string, mutate func(f *Form), want string) { t.Run(name, func(t *testing.T) { c := &Config{Forms: []Form{}} f := Form{ Name: "contact", Path: "/contact", Type: "contact", To: "owner@example.com", SMTP: SMTPConfig{Host: "smtp.example.com", Port: 587, User: "c@example.com"}, Telegram: &TelegramConfig{ BotToken: "123:secret", ChatID: "-100200300", }, } mutate(&f) c.Forms = append(c.Forms, f) err := c.validate() if want == "" { if err != nil { t.Fatalf("validate() = %v, want nil", err) } return } if err == nil || !strings.Contains(err.Error(), want) { t.Errorf("validate() = %v, want an error containing %q", err, want) } }) } test("newsletter rejected", func(f *Form) { f.Type = "newsletter" }, "telegram") test("missing token", func(f *Form) { f.Telegram.BotToken = "" }, "bot_token") test("missing chat", func(f *Form) { f.Telegram.ChatID = "" }, "chat_id") test("bad timeout", func(f *Form) { zero := 0 f.Telegram.TimeoutSeconds = &zero }, "timeout_seconds") t.Run("well configured contact form passes", func(t *testing.T) { c := &Config{Forms: []Form{}} f := Form{ Name: "contact", Path: "/contact", Type: "contact", To: "owner@example.com", SMTP: SMTPConfig{Host: "smtp.example.com", Port: 587, User: "c@example.com"}, Telegram: &TelegramConfig{ BotToken: "123:secret", ChatID: "@mychannel", }, } c.Forms = append(c.Forms, f) if err := c.validate(); err != nil { t.Fatalf("validate() = %v, want nil", err) } }) } func TestLoadExpandsEnvVars(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") doc := ` data_dir = "./data" [server] port = 9000 [[forms]] name = "contact" type = "contact" path = "/api/nuntius/contact" to = "you@example.com" [forms.smtp] host = "smtp.example.com" port = 587 user = "u@example.com" password = "${NUNTIUS_TEST_PW}" ` if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { t.Fatal(err) } t.Setenv("NUNTIUS_TEST_PW", "s3cret") cfg, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Forms[0].SMTP.Password != "s3cret" { t.Errorf("password = %q, want s3cret", cfg.Forms[0].SMTP.Password) } } func TestLoadRejectsUnknownField(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") doc := ` bogus = true [server] port = 8080 [[forms]] name = "contact" path = "/api/nuntius/contact" to = "you@example.com" [forms.smtp] host = "smtp.example.com" port = 587 user = "u@example.com" ` if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { t.Fatal(err) } if _, err := Load(path); err == nil { t.Fatal("expected an error for an unknown field") } } func TestConfigPath(t *testing.T) { t.Setenv("NUNTIUS_CONFIG", "") if got := ConfigPath(); got != "/etc/nuntius/config.toml" { t.Errorf("ConfigPath() = %q, want %q", got, "/etc/nuntius/config.toml") } path := filepath.Join(t.TempDir(), "config.toml") t.Setenv("NUNTIUS_CONFIG", path) if got := ConfigPath(); got != path { t.Errorf("ConfigPath() with NUNTIUS_CONFIG = %q, want %q", got, path) } } func TestSMTPConfigAddrFor(t *testing.T) { smtp := SMTPConfig{Host: "smtp.example.com", Port: 587} if got := smtp.AddrFor(); got != "smtp.example.com:587" { t.Errorf("AddrFor() = %q, want %q", got, "smtp.example.com:587") } } func TestIsValidFormType(t *testing.T) { for _, tc := range []struct { typ string want bool }{ {"contact", true}, {"feedback", true}, {"newsletter", true}, {"generic", true}, {"bogus", false}, {"", false}, } { if got := IsValidFormType(tc.typ); got != tc.want { t.Errorf("IsValidFormType(%q) = %v, want %v", tc.typ, got, tc.want) } } } func TestLoadValidatesPortRange(t *testing.T) { for _, port := range []int{-1, 65536, 100000} { path := filepath.Join(t.TempDir(), "config.toml") doc := fmt.Sprintf(` [server] port = %d [[forms]] name = "c" path = "/c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" `, port) if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { t.Fatal(err) } if _, err := Load(path); err == nil { t.Errorf("expected error for port %d", port) } } } func TestLoadRejectsNegativeRateLimit(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") doc := ` [[forms]] name = "c" path = "/c" to = "a@b.c" rate_limit_per_hour = -5 [forms.smtp] host = "h" port = 587 user = "u" ` if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { t.Fatal(err) } if _, err := Load(path); err == nil { t.Fatal("expected error for negative rate_limit_per_hour") } } func TestLoadRejectsInvalidName(t *testing.T) { for _, name := range []string{"bad/name", "bad name", "../evil", "name!"} { path := filepath.Join(t.TempDir(), "config.toml") doc := fmt.Sprintf(` [[forms]] name = %q path = "/c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" `, name) if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { t.Fatal(err) } if _, err := Load(path); err == nil { t.Errorf("expected error for name %q", name) } } } func TestLoadValidationErrors(t *testing.T) { base := func(mod string) string { return ` [[forms]] name = "c" path = "/c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" ` + mod } cases := []struct { name string doc string }{ {"missing name", ` [[forms]] path = "/c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" `}, {"missing path", ` [[forms]] name = "c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" `}, {"path without slash", ` [[forms]] name = "c" path = "c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" `}, {"wildcard path", ` [[forms]] name = "c" path = "/api/{form}" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" `}, {"space in path", ` [[forms]] name = "c" path = "/api/contact x" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" `}, {"empty path segment", ` [[forms]] name = "c" path = "/api//contact" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" `}, {"duplicate path", ` [[forms]] name = "c1" path = "/c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" [[forms]] name = "c2" path = "/c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" `}, {"invalid type", ` [[forms]] name = "c" path = "/c" type = "bogus" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" `}, {"missing smtp host", ` [[forms]] name = "c" path = "/c" to = "a@b.c" [forms.smtp] port = 587 user = "u" `}, {"missing smtp port", ` [[forms]] name = "c" path = "/c" to = "a@b.c" [forms.smtp] host = "h" user = "u" `}, {"missing smtp user", ` [[forms]] name = "c" path = "/c" to = "a@b.c" [forms.smtp] host = "h" port = 587 `}, {"missing to", ` [[forms]] name = "c" path = "/c" [forms.smtp] host = "h" port = 587 user = "u" `}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(tc.doc), 0o644); err != nil { t.Fatal(err) } if _, err := Load(path); err == nil { t.Fatalf("expected error for %s", tc.name) } }) } _ = base // silence unused helper } func TestLoadAppliesDefaults(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") doc := ` [[forms]] name = "c" path = "/c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" ` if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { t.Fatal(err) } cfg, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } if cfg.Server.Port != DefaultPort { t.Errorf("default port = %d, want %d", cfg.Server.Port, DefaultPort) } if cfg.DataDir != DefaultDataDir { t.Errorf("default data_dir = %q, want %q", cfg.DataDir, DefaultDataDir) } f := cfg.Forms[0] if f.Type != "contact" { t.Errorf("default type = %q, want contact", f.Type) } if f.RateLimit() != DefaultRateLimitPerHour { t.Errorf("default rate = %d, want %d", f.RateLimit(), DefaultRateLimitPerHour) } if f.Honeypot() != DefaultHoneypotField { t.Errorf("default honeypot = %q, want %q", f.Honeypot(), DefaultHoneypotField) } if f.From != "u" { t.Errorf("default from = %q, want smtp user", f.From) } } // Paths that survive validation must register as exact static routes. func TestLoadAcceptsDottedPath(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") doc := ` [[forms]] name = "c" path = "/api/v1.2/contact" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" ` if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { t.Fatal(err) } if _, err := Load(path); err != nil { t.Fatalf("Load: %v", err) } } // An explicit zero rate limit and an explicit empty honeypot name keep // their documented "disabled" meaning instead of falling back to defaults. func TestLoadHonoursExplicitZeroAndEmpty(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") doc := ` [[forms]] name = "c" path = "/c" to = "a@b.c" rate_limit_per_hour = 0 honeypot_field = "" [forms.smtp] host = "h" port = 587 user = "u" ` if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { t.Fatal(err) } cfg, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } if got := cfg.Forms[0].RateLimit(); got != 0 { t.Errorf("explicit zero rate = %d, want 0 (disabled)", got) } if got := cfg.Forms[0].Honeypot(); got != "" { t.Errorf("explicit empty honeypot = %q, want empty (disabled)", got) } } // A config that references an environment variable which is not set must // fail to load with a message naming the variable. func TestLoadRejectsUndefinedEnvVar(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") doc := ` [[forms]] name = "c" path = "/c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" password = "${NUNTIUS_MISSING_PW}" ` if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { t.Fatal(err) } _, err := Load(path) if err == nil { t.Fatal("expected an error for the undefined variable") } if !strings.Contains(err.Error(), "NUNTIUS_MISSING_PW") { t.Errorf("error %q should name the missing variable", err) } } // Comment lines are documentation: placeholders shown there, such as the // ${VAR_NAME} mention in the generated template header, must survive // expansion untouched. func TestExpandConfigSkipsCommentLines(t *testing.T) { t.Setenv("NUNTIUS_TEST_A", "alpha") in := "# see ${NUNTIUS_DOCS_VAR}\nvalue = \"${NUNTIUS_TEST_A}\"\n" out, err := expandConfig(in) if err != nil { t.Fatalf("expandConfig: %v", err) } want := "# see ${NUNTIUS_DOCS_VAR}\nvalue = \"alpha\"\n" if out != want { t.Errorf("expandConfig() = %q, want %q", out, want) } } // minimalDoc is the smallest config that passes validation: every new // policy key omitted, so the getters must return today's defaults. const minimalDoc = ` [[forms]] name = "c" path = "/c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" ` func loadDoc(t *testing.T, doc string) *Config { t.Helper() path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { t.Fatal(err) } cfg, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } return cfg } // Every policy key left out must resolve to the value the hardcoded // constants carried before the keys existed: zero config behaves exactly // like the previous release. func TestLoadAppliesPolicyDefaults(t *testing.T) { cfg := loadDoc(t, minimalDoc) s := cfg.Server if s.Bind != "::" { t.Errorf("default bind = %q, want ::", s.Bind) } for name, got := range map[string]time.Duration{ "read_header_timeout": s.ReadHeaderTimeout(), "read_timeout": s.ReadTimeout(), "write_timeout": s.WriteTimeout(), "idle_timeout": s.IdleTimeout(), "shutdown_timeout": s.ShutdownTimeout(), "rate_limit_cleanup": s.RateLimitCleanup(), "rate_limit_max_age": s.RateLimitMaxBucketAge(), } { if got <= 0 { t.Errorf("default %s = %v, want a positive duration", name, got) } } if s.ReadHeaderTimeout() != 10*time.Second || s.ReadTimeout() != 15*time.Second { t.Errorf("read timeouts = %v/%v, want 10s/15s", s.ReadHeaderTimeout(), s.ReadTimeout()) } if s.WriteTimeout() != 30*time.Second || s.IdleTimeout() != 60*time.Second { t.Errorf("write/idle timeouts = %v/%v, want 30s/60s", s.WriteTimeout(), s.IdleTimeout()) } if s.ShutdownTimeout() != 15*time.Second { t.Errorf("shutdown timeout = %v, want 15s", s.ShutdownTimeout()) } if s.BodyLimit() != 1<<20 { t.Errorf("default body limit = %d, want 1 MiB", s.BodyLimit()) } if s.MaxRateLimitBuckets() != 32768 { t.Errorf("default bucket cap = %d, want 32768", s.MaxRateLimitBuckets()) } if s.RateLimitCleanup() != time.Hour || s.RateLimitMaxBucketAge() != 2*time.Hour { t.Errorf("cleanup/max age = %v/%v, want 1h/2h", s.RateLimitCleanup(), s.RateLimitMaxBucketAge()) } f := cfg.Forms[0] if f.PendingTTL() != 72*time.Hour { t.Errorf("default pending TTL = %v, want 72h", f.PendingTTL()) } if f.SMTP.Timeout() != 20*time.Second { t.Errorf("default smtp timeout = %v, want 20s", f.SMTP.Timeout()) } if f.EmailSubjectPrefix() != "nuntius" { t.Errorf("default subject prefix = %q, want nuntius", f.EmailSubjectPrefix()) } if f.Brand() != "nuntius" { t.Errorf("default brand = %q, want nuntius", f.Brand()) } // The contact preset carries the built-in service list and the // historical length limits. p := f.Policy() if !p.RequireName || !p.RequireMessage { t.Error("contact preset must require name and message") } if p.MinNameRunes != 2 || p.MaxNameRunes != 100 || p.MinMessageRunes != 10 || p.MaxMessageRunes != 5000 { t.Errorf("contact preset limits = %+v, want 2/100/10/5000", p) } if len(p.Services) != 6 || !slices.Contains(p.Services, "architecture") { t.Errorf("contact preset services = %v, want the built-in list", p.Services) } } // Setting a policy key must move the effective value away from the // default, and the changed value must reach the getter and the policy. func TestLoadHonoursPolicyOverrides(t *testing.T) { cfg := loadDoc(t, ` [server] bind = "127.0.0.1" read_header_timeout_seconds = 5 read_timeout_seconds = 0 write_timeout_seconds = 120 idle_timeout_seconds = 300 shutdown_timeout_seconds = 1 max_body_bytes = 65536 rate_limit_max_buckets = 1024 rate_limit_cleanup_seconds = 60 rate_limit_max_bucket_age_seconds = 600 [[forms]] name = "c" path = "/c" to = "a@b.c" services = ["consulting", "support"] require_name = false min_name_runes = 3 max_name_runes = 50 min_message_runes = 1 max_message_runes = 2000 pending_ttl_seconds = 3600 subject_prefix = "web" email_brand = "" [forms.smtp] host = "h" port = 587 user = "u" timeout_seconds = 45 `) s := cfg.Server if s.Bind != "127.0.0.1" { t.Errorf("bind = %q, want 127.0.0.1", s.Bind) } if s.ReadHeaderTimeout() != 5*time.Second { t.Errorf("read header timeout = %v, want 5s", s.ReadHeaderTimeout()) } // An explicit 0 keeps its net/http meaning: switched off. if s.ReadTimeout() != 0 { t.Errorf("read timeout = %v, want 0 (disabled)", s.ReadTimeout()) } if s.WriteTimeout() != 120*time.Second || s.IdleTimeout() != 300*time.Second { t.Errorf("write/idle = %v/%v, want 120s/300s", s.WriteTimeout(), s.IdleTimeout()) } if s.ShutdownTimeout() != time.Second { t.Errorf("shutdown = %v, want 1s", s.ShutdownTimeout()) } if s.BodyLimit() != 65536 { t.Errorf("body limit = %d, want 65536", s.BodyLimit()) } if s.MaxRateLimitBuckets() != 1024 || s.RateLimitCleanup() != time.Minute || s.RateLimitMaxBucketAge() != 10*time.Minute { t.Errorf("limiter settings = %d/%v/%v, want 1024/1m/10m", s.MaxRateLimitBuckets(), s.RateLimitCleanup(), s.RateLimitMaxBucketAge()) } f := cfg.Forms[0] if f.PendingTTL() != time.Hour { t.Errorf("pending TTL = %v, want 1h", f.PendingTTL()) } if f.SMTP.Timeout() != 45*time.Second { t.Errorf("smtp timeout = %v, want 45s", f.SMTP.Timeout()) } if f.EmailSubjectPrefix() != "web" { t.Errorf("subject prefix = %q, want web", f.EmailSubjectPrefix()) } // An explicit empty brand disables the footer. if f.Brand() != "" { t.Errorf("brand = %q, want empty (disabled)", f.Brand()) } p := f.Policy() if p.RequireName { t.Error("require_name = false must reach the policy") } if p.RequireMessage != true { t.Error("unset require_message must keep the contact preset value") } if p.MinNameRunes != 3 || p.MaxNameRunes != 50 || p.MinMessageRunes != 1 || p.MaxMessageRunes != 2000 { t.Errorf("policy limits = %+v, want 3/50/1/2000", p) } if !slices.Contains(p.Services, "consulting") || slices.Contains(p.Services, "architecture") { t.Errorf("policy services = %v, want the configured list", p.Services) } } // The services key must also work on non-contact forms: it switches the // service field into the validation for any type. func TestFormPolicyServicesOnFeedback(t *testing.T) { cfg := loadDoc(t, ` [[forms]] name = "f" path = "/f" type = "feedback" to = "a@b.c" services = ["bug", "idea"] [forms.smtp] host = "h" port = 587 user = "u" `) p := cfg.Forms[0].Policy() if p.Services == nil { t.Fatal("feedback with a services key must validate the service field") } r := &contactform.Request{Name: "Jane", Email: "jane@example.com", Service: "bug", Message: "A message long enough."} if errs := contactform.Validate(r, p); len(errs) > 0 { t.Errorf("listed service must pass, got %v", errs) } r2 := &contactform.Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."} if errs := contactform.Validate(r2, p); len(errs) == 0 || errs[0].Field != "service" { t.Errorf("unlisted service must fail, got %v", errs) } // The preset without the key must leave the field unvalidated. cfg2 := loadDoc(t, ` [[forms]] name = "f" path = "/f" type = "feedback" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" `) if p2 := cfg2.Forms[0].Policy(); p2.Services != nil { t.Errorf("feedback without services key = %v, want nil", p2.Services) } } // An explicit empty services list accepts only the empty value. The empty // array must decode as a present-but-empty list, not as an omitted key. func TestFormPolicyEmptyServicesList(t *testing.T) { cfg := loadDoc(t, ` [[forms]] name = "c" path = "/c" to = "a@b.c" services = [] [forms.smtp] host = "h" port = 587 user = "u" `) f := cfg.Forms[0] if f.Services == nil { t.Fatal("services = [] must decode as an explicit empty list, not an omitted key") } if len(f.Services) != 0 { t.Fatalf("services = %v, want empty", f.Services) } p := f.Policy() if errs := contactform.Validate(&contactform.Request{Name: "Jane", Email: "jane@example.com", Service: "anything", Message: "A message long enough."}, p); len(errs) == 0 || errs[0].Field != "service" { t.Errorf("empty list must reject a service value, got %v", errs) } if errs := contactform.Validate(&contactform.Request{Name: "Jane", Email: "jane@example.com", Service: "", Message: "A message long enough."}, p); len(errs) != 0 { t.Errorf("empty list must keep the empty value valid, got %v", errs) } } // Nonsense policy values must fail at load time with an error naming the // offending key. func TestLoadRejectsInvalidPolicyValues(t *testing.T) { tests := []struct { name string mod string wantErr string }{ {"negative read timeout", "read_timeout_seconds = -1", "server.read_timeout_seconds"}, {"negative idle timeout", "idle_timeout_seconds = -5", "server.idle_timeout_seconds"}, {"zero body limit", "max_body_bytes = 0", "server.max_body_bytes"}, {"negative body limit", "max_body_bytes = -3", "server.max_body_bytes"}, {"zero bucket cap", "rate_limit_max_buckets = 0", "server.rate_limit_max_buckets"}, {"zero cleanup tick", "rate_limit_cleanup_seconds = 0", "server.rate_limit_cleanup_seconds"}, {"zero bucket age", "rate_limit_max_bucket_age_seconds = 0", "server.rate_limit_max_bucket_age_seconds"}, {"bind with whitespace", "bind = \":: 80\"", "server.bind"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { doc := ` [server] ` + tc.mod + ` [[forms]] name = "c" path = "/c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" ` path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { t.Fatal(err) } _, err := Load(path) if err == nil { t.Fatalf("expected an error for %s", tc.name) } if !strings.Contains(err.Error(), tc.wantErr) { t.Errorf("error %q should name %q", err, tc.wantErr) } }) } formMods := []struct { name string doc string wantErr string }{ {"zero pending ttl", ` [[forms]] name = "c" path = "/c" to = "a@b.c" pending_ttl_seconds = 0 [forms.smtp] host = "h" port = 587 user = "u" `, "pending_ttl_seconds"}, {"negative pending ttl", ` [[forms]] name = "c" path = "/c" to = "a@b.c" pending_ttl_seconds = -3600 [forms.smtp] host = "h" port = 587 user = "u" `, "pending_ttl_seconds"}, {"zero smtp timeout", ` [[forms]] name = "c" path = "/c" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" timeout_seconds = 0 `, "smtp.timeout_seconds"}, {"empty service entry", ` [[forms]] name = "c" path = "/c" to = "a@b.c" services = ["", "x"] [forms.smtp] host = "h" port = 587 user = "u" `, "services"}, {"padded service entry", ` [[forms]] name = "c" path = "/c" to = "a@b.c" services = [" x"] [forms.smtp] host = "h" port = 587 user = "u" `, "services"}, {"name window inverted", ` [[forms]] name = "c" path = "/c" to = "a@b.c" max_name_runes = 1 [forms.smtp] host = "h" port = 587 user = "u" `, "max_name_runes"}, {"negative name minimum", ` [[forms]] name = "c" path = "/c" to = "a@b.c" min_name_runes = -1 [forms.smtp] host = "h" port = 587 user = "u" `, "min_name_runes"}, {"zero name maximum", ` [[forms]] name = "c" path = "/c" to = "a@b.c" max_name_runes = 0 [forms.smtp] host = "h" port = 587 user = "u" `, "max_name_runes"}, {"message window inverted", ` [[forms]] name = "c" path = "/c" to = "a@b.c" min_message_runes = 6000 [forms.smtp] host = "h" port = 587 user = "u" `, "max_message_runes"}, {"zero message maximum", ` [[forms]] name = "c" path = "/c" to = "a@b.c" max_message_runes = 0 [forms.smtp] host = "h" port = 587 user = "u" `, "max_message_runes"}, } for _, tc := range formMods { t.Run(tc.name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(tc.doc), 0o644); err != nil { t.Fatal(err) } _, err := Load(path) if err == nil { t.Fatalf("expected an error for %s", tc.name) } if !strings.Contains(err.Error(), tc.wantErr) { t.Errorf("error %q should name %q", err, tc.wantErr) } }) } } // The newsletter preset leaves the free-text fields out of the validation // and the pending TTL default matches the documented 72 hours. func TestNewsletterPolicyAndPendingDefaults(t *testing.T) { cfg := loadDoc(t, ` [[forms]] name = "n" path = "/n" type = "newsletter" to = "a@b.c" [forms.smtp] host = "h" port = 587 user = "u" `) f := cfg.Forms[0] p := f.Policy() if p.RequireName || p.RequireMessage || p.Services != nil { t.Errorf("newsletter policy = %+v, want email-only", p) } if f.PendingTTL() != 72*time.Hour { t.Errorf("pending TTL = %v, want 72h", f.PendingTTL()) } } func TestExpandEnv(t *testing.T) { t.Setenv("NUNTIUS_TEST_A", "alpha") t.Setenv("NUNTIUS_TEST_EMPTY", "") tests := []struct { name string in string want string wantErr string }{ {"no references", "plain text", "plain text", ""}, {"braced reference", "${NUNTIUS_TEST_A}", "alpha", ""}, {"bare reference", "$NUNTIUS_TEST_A!", "alpha!", ""}, {"both syntaxes", "${NUNTIUS_TEST_A}/$NUNTIUS_TEST_A", "alpha/alpha", ""}, { name: "unset braced variable errors", in: "pw = ${NUNTIUS_UNSET_X}", want: "", wantErr: "NUNTIUS_UNSET_X", }, { name: "unset bare variable errors", in: "$UNSET_Y", want: "", wantErr: "UNSET_Y", }, {"set but empty expands to empty", "[${NUNTIUS_TEST_EMPTY}]", "[]", ""}, {"lone dollar stays literal", "100$ total", "100$ total", ""}, {"dollar before space stays literal", "$ is money", "$ is money", ""}, {"double dollar stays literal", "$$", "$$", ""}, {"unterminated brace keeps dollar", "${UNCLOSED", "${UNCLOSED", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := expandEnv(tt.in) if tt.wantErr != "" { if err == nil { t.Fatalf("expandEnv(%q) = %q, want error containing %q", tt.in, got, tt.wantErr) } if !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("error %q should contain %q", err, tt.wantErr) } return } if err != nil { t.Fatalf("expandEnv(%q): %v", tt.in, err) } if got != tt.want { t.Errorf("expandEnv(%q) = %q, want %q", tt.in, got, tt.want) } }) } } // FuzzLoadConfig throws arbitrary documents at the loader. Anything that // loads must satisfy the invariants validate() applies: a usable port, // at least one form, known types, a recipient per form, and no // whitespace inside a redirect_url. Documents that fail to load are // uninteresting by definition. func FuzzLoadConfig(f *testing.F) { seeds := []string{ "", "not toml at all }}}", "[server]\nport = 70000\n", "[[forms]]\nname = \"a\"\n", "data_dir = \"./data\"\n[server]\nport = 9000\n[[forms]]\nname = \"a\"\npath = \"/a\"\nto = \"o@example.com\"\n[forms.smtp]\nhost = \"h\"\nport = 587\nuser = \"u\"\n", "[[forms]]\nname = \"a\"\npath = \"/a\"\ntype = \"contact\"\nto = \"o@example.com\"\nredirect_url = \"https://example.com/thanks\"\nsmtp = {host = \"h\", port = 587, user = \"u\"}\n", } for _, s := range seeds { f.Add(s) } f.Fuzz(func(t *testing.T, doc string) { path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(doc), 0644); err != nil { t.Skip() } cfg, err := Load(path) if err != nil { return } if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { t.Fatalf("loaded config with port %d", cfg.Server.Port) } if len(cfg.Forms) == 0 { t.Fatalf("loaded config without forms") } for _, form := range cfg.Forms { if !IsValidFormType(form.Type) { t.Fatalf("loaded form %q with unknown type %q", form.Name, form.Type) } if form.To == "" { t.Fatalf("loaded form %q without a recipient", form.Name) } if strings.ContainsAny(form.RedirectURL, " \t\r\n") { t.Fatalf("loaded form %q with whitespace in redirect_url", form.Name) } if form.Archive && form.Type == "newsletter" { t.Fatalf("loaded newsletter form %q with archive", form.Name) } if form.AutoReply && form.Type == "newsletter" { t.Fatalf("loaded newsletter form %q with auto_reply", form.Name) } } }) }