Smail/backend/config/validator_security_test.go
smail 833aac2fbb 补充SMTP中继开关测试夹具
更新测试场景,明确只有显式启用中继时才校验和投递,验证旧配置不再自动启用。
2026-10-06 00:32:43 +08:00

312 lines
8.2 KiB
Go

package config
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
)
func TestValidateRootConfigRejectsUnsafeValues(t *testing.T) {
tests := []struct {
name string
mutate func(*RootConfig)
want error
}{
{
name: "invalid_log_level",
mutate: func(root *RootConfig) {
root.LogLevel = "trace"
},
want: ErrInvalidLogLevel,
},
{
name: "invalid_http_port",
mutate: func(root *RootConfig) {
root.HTTP.Port = 70000
},
want: ErrInvalidHTTP,
},
{
name: "https_requires_port",
mutate: func(root *RootConfig) {
root.HTTP.HTTPSEnabled = true
root.HTTP.HTTPSPort = 0
},
want: ErrInvalidHTTP,
},
{
name: "invalid_email_policy",
mutate: func(root *RootConfig) {
root.Email.LoginVerificationPolicy = "sometimes"
},
want: ErrInvalidEmail,
},
{
name: "invalid_smtp_host",
mutate: func(root *RootConfig) {
root.SMTP = SMTPConfig{
RelayHost: "smtp.example.com/relay",
RelayPort: 587,
Security: "starttls",
FromDomain: "mail.example.com",
VerificationEnabled: true,
}
},
want: ErrInvalidSMTP,
},
{
name: "remote_plain_smtp_relay",
mutate: func(root *RootConfig) {
root.SMTP = SMTPConfig{
RelayHost: "smtp.example.com",
RelayPort: 25,
Security: "plain",
FromDomain: "mail.example.com",
VerificationEnabled: true,
}
},
want: ErrInvalidSMTP,
},
{
name: "inconsistent_disabled_smtp_secret",
mutate: func(root *RootConfig) {
root.SMTP.Password = StoredSecret{Ciphertext: "ENC[v1]invalid", Version: 1}
},
want: ErrInvalidSMTP,
},
{
name: "ssl_path_escape",
mutate: func(root *RootConfig) {
root.SSL.CertificateRef = "config/ssl/../outside.pem"
root.SSL.PrivateKeyRef = "config/ssl/key.pem"
},
want: ErrInvalidSSL,
},
{
name: "ssl_certificate_key_mismatch",
mutate: func(root *RootConfig) {
root.SSL.CertificateRef = "config/ssl/certificate.pem"
},
want: ErrInvalidSSL,
},
{
name: "duplicate_plugin_allowlist_target",
mutate: func(root *RootConfig) {
root.Plugins = []PluginConfig{{
LogicalID: "telegram_push",
FileName: "telegram_push",
NetworkAllowlist: []string{"api.example.com", "api.example.com"},
}}
},
want: ErrInvalidPlugin,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := testRootConfig()
test.mutate(&root)
if err := ValidateRootConfig(root); !errors.Is(err, test.want) {
t.Fatalf("error = %v, want %v", err, test.want)
}
})
}
}
func TestValidateRuntimeConfigRejectsUnsafeCrossSectionValues(t *testing.T) {
tests := []struct {
name string
mutate func(*RuntimeConfig)
want error
}{
{
name: "duplicate_accepted_domain",
mutate: func(runtime *RuntimeConfig) {
runtime.Root.Domains.Accepted = []string{"mail.example.com", "mail.example.com"}
},
want: ErrInvalidDomainConfig,
},
{
name: "ready_requires_web_domain",
mutate: func(runtime *RuntimeConfig) {
runtime.Root.Domains.Web = ""
},
want: ErrInvalidDomainConfig,
},
{
name: "duplicate_plugin_network_target",
mutate: func(runtime *RuntimeConfig) {
plugin := runtime.Plugins.Plugins["telegram_push"]
plugin.Permissions.Network = append(plugin.Permissions.Network, plugin.Permissions.Network[0])
runtime.Plugins.Plugins["telegram_push"] = plugin
},
want: ErrInvalidPlugin,
},
{
name: "plugin_network_ip_target",
mutate: func(runtime *RuntimeConfig) {
plugin := runtime.Plugins.Plugins["telegram_push"]
plugin.Permissions.Network[0].Host = "127.0.0.1"
runtime.Plugins.Plugins["telegram_push"] = plugin
},
want: ErrInvalidPlugin,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runtime := validRuntimeConfig()
test.mutate(&runtime)
if err := ValidateRuntimeConfig(runtime, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); !errors.Is(err, test.want) {
t.Fatalf("error = %v, want %v", err, test.want)
}
})
}
}
func TestValidateSMTPKeepsLoginVerificationIndependentFromRelay(t *testing.T) {
tests := []struct {
name string
smtp SMTPConfig
valid bool
}{
{
name: "verification enabled without relay",
smtp: SMTPConfig{
VerificationEnabled: true,
LoginPolicy: "new_device",
FromDomain: "example.com",
},
valid: true,
},
{
name: "unified defaults without relay host",
smtp: SMTPConfig{
RelayPort: 587,
Security: "starttls",
FromDomain: "example.com",
},
valid: true,
},
{
name: "partially configured relay",
smtp: SMTPConfig{
RelayEnabled: true,
RelayHost: "smtp.example.com",
RelayPort: 587,
Security: "starttls",
LoginPolicy: "new_device",
},
},
{
name: "complete relay",
smtp: SMTPConfig{
RelayEnabled: true,
RelayHost: "smtp.example.com",
RelayPort: 587,
Security: "starttls",
From: "no-reply@example.com",
FromDomain: "example.com",
LoginPolicy: "new_device",
},
valid: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateSMTP(test.smtp)
if test.valid && err != nil {
t.Fatalf("validateSMTP() error = %v", err)
}
if !test.valid && !errors.Is(err, ErrInvalidSMTP) {
t.Fatalf("validateSMTP() error = %v, want %v", err, ErrInvalidSMTP)
}
})
}
}
func TestSnapshotStoreRejectsInvalidCandidateWithoutChangingSnapshot(t *testing.T) {
initial := validRuntimeConfig()
store, err := NewSnapshotStore(initial, RuntimeValidationOptions{HasSuperAdmin: true, Production: true})
if err != nil {
t.Fatal(err)
}
before, err := store.Snapshot()
if err != nil {
t.Fatal(err)
}
invalid := cloneRuntimeConfig(initial)
invalid.Email.IMAP.TLSPort = invalid.Root.HTTP.HTTPSPort
if err := store.Publish(invalid); !errors.Is(err, ErrPortConflict) {
t.Fatalf("publish error = %v, want %v", err, ErrPortConflict)
}
if store.Generation() != before.Generation {
t.Fatalf("generation = %d, want %d", store.Generation(), before.Generation)
}
after, err := store.Snapshot()
if err != nil {
t.Fatal(err)
}
if after.Email.IMAP.TLSPort != before.Email.IMAP.TLSPort {
t.Fatalf("snapshot changed after rejection: before=%d after=%d", before.Email.IMAP.TLSPort, after.Email.IMAP.TLSPort)
}
}
func TestConfigManagerRejectsInvalidUpdateWithoutDiskOrStatusMutation(t *testing.T) {
root := t.TempDir()
writeRuntimeFixture(t, root, validRuntimeConfig())
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
manager, err := NewConfigManager(root, options)
if err != nil {
t.Fatal(err)
}
beforeFiles := make(map[string][]byte)
for _, name := range []string{"config.json", "smtp.json", "email.json", "ssl.json", "plugins.json"} {
data, readErr := os.ReadFile(filepath.Join(root, "config", name))
if readErr != nil {
t.Fatal(readErr)
}
beforeFiles[name] = data
}
beforeStatus := manager.Status()
current := manager.Snapshot()
invalidEmail := current.Email
invalidEmail.IMAP.TLSPort = current.Root.HTTP.HTTPSPort
payload, err := json.Marshal(invalidEmail)
if err != nil {
t.Fatal(err)
}
if _, err := manager.Update(context.Background(), UpdateRequest{
Section: SectionEmail,
ExpectedGeneration: current.Generation,
Payload: payload,
Actor: authorizedConfigActor(),
}); !errors.Is(err, ErrPortConflict) {
t.Fatalf("update error = %v, want %v", err, ErrPortConflict)
}
if got := manager.Snapshot().Generation; got != beforeStatus.ActiveGeneration {
t.Fatalf("active generation = %d, want %d", got, beforeStatus.ActiveGeneration)
}
afterStatus := manager.Status()
if afterStatus.ActiveGeneration != beforeStatus.ActiveGeneration || len(afterStatus.PendingRestart) != 0 {
t.Fatalf("status changed after rejected update: before=%+v after=%+v", beforeStatus, afterStatus)
}
for name, before := range beforeFiles {
after, readErr := os.ReadFile(filepath.Join(root, "config", name))
if readErr != nil {
t.Fatal(readErr)
}
if !bytes.Equal(after, before) {
t.Fatalf("configuration file %s changed after rejected update", name)
}
}
}