Files
nuntius/internal/config/config_test.go
T

1194 lines
29 KiB
Go
Raw Normal View History

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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)
}
}
})
}