724 lines
23 KiB
Go
724 lines
23 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"git.pcux.cn/xuans/Smail/utils/credential"
|
|
)
|
|
|
|
func writeLegacyMigrationFixture(t *testing.T, root string, value any) []byte {
|
|
t.Helper()
|
|
directory := filepath.Join(root, "config")
|
|
if err := os.MkdirAll(directory, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(directory, "config.json"), data, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func TestMigrateLegacyConfigPreservesBackupAndLoadsPartitions(t *testing.T) {
|
|
root := t.TempDir()
|
|
legacy := map[string]any{
|
|
"logLevel": "info",
|
|
"domain": "mail.example.com",
|
|
"domains": []string{"mail.example.com", "example.com"},
|
|
"webDomain": "mail.example.com",
|
|
"sslType": "1",
|
|
"SSLPrivateKeyPath": "./config/ssl/private.key",
|
|
"SSLPublicKeyPath": "./config/ssl/public.crt",
|
|
"dbDSN": "./config/smail.db",
|
|
"dbType": "sqlite",
|
|
"httpsEnabled": 1,
|
|
"httpPort": 80,
|
|
"httpsPort": 443,
|
|
"weChatPushAppId": "app-id",
|
|
"weChatPushSecret": "wechat-secret",
|
|
"weChatPushTemplateId": "template-id",
|
|
"isInit": true,
|
|
}
|
|
original := writeLegacyMigrationFixture(t, root, legacy)
|
|
|
|
result, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Generation != 1 || result.BackupPath == "" {
|
|
t.Fatalf("unexpected migration result: %+v", result)
|
|
}
|
|
backup, err := os.ReadFile(result.BackupPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(backup, original) {
|
|
t.Fatal("legacy backup does not match the original configuration")
|
|
}
|
|
legacyCopy, err := os.ReadFile(filepath.Join(root, "config", legacyMigrationSourceCopy))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(legacyCopy, original) {
|
|
t.Fatal("legacy source copy does not match the original configuration")
|
|
}
|
|
loader := NewConfigLoader(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}})
|
|
runtime, err := loader.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if runtime.Root.Database.LocalRef != "smail.db" || runtime.Plugins.Plugins["wechat_push"].Secret.Ciphertext == "" {
|
|
t.Fatalf("unexpected migrated runtime: %+v", runtime)
|
|
}
|
|
manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := manager.Load(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if manager.Snapshot().Generation != result.Generation {
|
|
t.Fatalf("manager generation = %d, want %d", manager.Snapshot().Generation, result.Generation)
|
|
}
|
|
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)
|
|
}
|
|
if bytes.Contains(data, []byte("wechat-secret")) {
|
|
t.Fatalf("plaintext credential leaked to %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLegacySecurityDefaultsPreserveExplicitValues(t *testing.T) {
|
|
falseValue := false
|
|
tests := []struct {
|
|
name string
|
|
legacy legacyMigrationConfig
|
|
verification bool
|
|
recovery bool
|
|
policy string
|
|
}{
|
|
{
|
|
name: "missing values use secure defaults",
|
|
verification: true,
|
|
recovery: true,
|
|
policy: "new_device",
|
|
},
|
|
{
|
|
name: "explicit false values are preserved",
|
|
legacy: legacyMigrationConfig{
|
|
VerificationEnabled: &falseValue,
|
|
ExternalRecoveryEnabled: &falseValue,
|
|
LoginPolicy: "off",
|
|
},
|
|
policy: "off",
|
|
},
|
|
{
|
|
name: "legacy policy is preserved",
|
|
legacy: legacyMigrationConfig{
|
|
VerificationLoginPolicy: "always",
|
|
},
|
|
verification: true,
|
|
recovery: true,
|
|
policy: "always",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
runtime, manualFields, err := legacyRuntimeConfig(t.TempDir(), test.legacy)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(manualFields) != 0 {
|
|
t.Fatalf("unexpected manual fields: %+v", manualFields)
|
|
}
|
|
if runtime.SMTP.Relay.VerificationEnabled != test.verification ||
|
|
runtime.Email.Policy.ExternalRecoveryEnabled != test.recovery ||
|
|
runtime.SMTP.Relay.LoginPolicy != test.policy ||
|
|
runtime.Email.Policy.LoginVerificationPolicy != test.policy {
|
|
t.Fatalf("security migration = verification %t, recovery %t, smtp policy %q, email policy %q",
|
|
runtime.SMTP.Relay.VerificationEnabled,
|
|
runtime.Email.Policy.ExternalRecoveryEnabled,
|
|
runtime.SMTP.Relay.LoginPolicy,
|
|
runtime.Email.Policy.LoginVerificationPolicy,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyConfigResumesAfterNonSecretPartitionWrite(t *testing.T) {
|
|
root := t.TempDir()
|
|
original := writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"domain": "mail.example.com",
|
|
"domains": []string{"mail.example.com"},
|
|
"webDomain": "mail.example.com",
|
|
"sslType": "1",
|
|
"SSLPrivateKeyPath": "./config/ssl/private.key",
|
|
"SSLPublicKeyPath": "./config/ssl/public.crt",
|
|
"isInit": true,
|
|
"httpsEnabled": 1,
|
|
"httpPort": 80,
|
|
"httpsPort": 443,
|
|
})
|
|
var legacy legacyMigrationConfig
|
|
if err := json.Unmarshal(original, &legacy); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runtime, manualFields, err := legacyRuntimeConfig(root, legacy)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(manualFields) != 0 {
|
|
t.Fatalf("unexpected manual fields: %+v", manualFields)
|
|
}
|
|
files, err := marshalRuntimeFilesWithPluginSecrets(runtime, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
directory := filepath.Join(root, "config")
|
|
for _, section := range []Section{SectionEmail, SectionSSL} {
|
|
name, ok := FixedSectionFile(section)
|
|
if !ok {
|
|
t.Fatalf("missing fixed file for section %q", section)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(directory, name), files[name], 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
legacyCopy, err := os.ReadFile(filepath.Join(directory, legacyMigrationSourceCopy))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(legacyCopy, original) {
|
|
t.Fatal("resumed migration did not preserve the legacy source")
|
|
}
|
|
}
|
|
|
|
func TestPlanLegacyMigrationReportsUnknownFieldsWithoutOverwritingConfig(t *testing.T) {
|
|
root := t.TempDir()
|
|
original := writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"unsupported": true,
|
|
})
|
|
plan, err := PlanLegacyMigration(root, RuntimeValidationOptions{})
|
|
if !errors.Is(err, ErrMigrationUnknownField) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if len(plan.UnknownFields) != 1 || plan.UnknownFields[0] != "unsupported" {
|
|
t.Fatalf("unexpected unknown fields: %+v", plan.UnknownFields)
|
|
}
|
|
current, readErr := os.ReadFile(filepath.Join(root, "config", "config.json"))
|
|
if readErr != nil {
|
|
t.Fatal(readErr)
|
|
}
|
|
if !bytes.Equal(current, original) {
|
|
t.Fatal("migration planning overwrote the original configuration")
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyConfigRejectsManualCredentialsWithoutOverwritingConfig(t *testing.T) {
|
|
root := t.TempDir()
|
|
original := writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"domain": "mail.example.com",
|
|
"domains": []string{"mail.example.com"},
|
|
"webDomain": "mail.example.com",
|
|
"sslType": "0",
|
|
"isInit": true,
|
|
"httpsEnabled": 1,
|
|
"httpsPort": 443,
|
|
"tgBotToken": "token",
|
|
"tgChatId": "123456",
|
|
"httpPort": 80,
|
|
"spamFilterLevel": 0,
|
|
})
|
|
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); !errors.Is(err, ErrMigrationUnsafeValue) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
current, err := os.ReadFile(filepath.Join(root, "config", "config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(current, original) {
|
|
t.Fatal("failed migration overwrote the original configuration")
|
|
}
|
|
for _, name := range []string{"smtp.json", "email.json", "ssl.json", "plugins.json"} {
|
|
if _, err := os.Stat(filepath.Join(root, "config", name)); !os.IsNotExist(err) {
|
|
t.Fatalf("failed migration created %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyConfigReencryptsExistingLegacyCredential(t *testing.T) {
|
|
root := t.TempDir()
|
|
if _, err := credential.NewVault(root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
encrypted, err := credential.Encrypt(root, "telegram-token")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"domain": "mail.example.com",
|
|
"domains": []string{"mail.example.com"},
|
|
"webDomain": "mail.example.com",
|
|
"sslType": "0",
|
|
"isInit": true,
|
|
"httpsEnabled": 1,
|
|
"httpsPort": 443,
|
|
"tgBotToken": encrypted,
|
|
"httpPort": 80,
|
|
})
|
|
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runtime, err := NewConfigLoader(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}).Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
opened, err := OpenStoredSecret(mustMigrationVault(t, root), runtime.Plugins.Plugins["telegram_push"].Secret)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if opened != "telegram-token" {
|
|
t.Fatalf("opened secret = %q", opened)
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyConfigRejectsCorruptedLegacyCredential(t *testing.T) {
|
|
root := t.TempDir()
|
|
original := writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"domain": "mail.example.com",
|
|
"domains": []string{"mail.example.com"},
|
|
"webDomain": "mail.example.com",
|
|
"sslType": "0",
|
|
"isInit": true,
|
|
"httpsEnabled": 1,
|
|
"httpPort": 80,
|
|
"httpsPort": 443,
|
|
"tgBotToken": "enc:v1:not-a-valid-ciphertext",
|
|
})
|
|
|
|
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); !errors.Is(err, ErrMigrationUnsafeValue) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
current, err := os.ReadFile(filepath.Join(root, "config", "config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(current, original) {
|
|
t.Fatal("corrupted credential migration overwrote the legacy configuration")
|
|
}
|
|
for _, name := range []string{"smtp.json", "email.json", "ssl.json", "plugins.json"} {
|
|
if _, err := os.Stat(filepath.Join(root, "config", name)); !os.IsNotExist(err) {
|
|
t.Fatalf("corrupted credential migration created %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyConfigRejectsConflictingPreservedSource(t *testing.T) {
|
|
root := t.TempDir()
|
|
original := writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"domain": "mail.example.com",
|
|
"domains": []string{"mail.example.com"},
|
|
"webDomain": "mail.example.com",
|
|
"sslType": "0",
|
|
"isInit": true,
|
|
"httpsEnabled": 1,
|
|
"httpPort": 80,
|
|
"httpsPort": 443,
|
|
})
|
|
legacyCopyPath := filepath.Join(root, "config", legacyMigrationSourceCopy)
|
|
conflictingCopy := []byte(`{"dbDSN":"different.db","dbType":"sqlite"}`)
|
|
if err := os.WriteFile(legacyCopyPath, conflictingCopy, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); !errors.Is(err, ErrMigrationTargetExists) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
current, err := os.ReadFile(filepath.Join(root, "config", "config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(current, original) {
|
|
t.Fatal("conflicting preserved source changed the legacy configuration")
|
|
}
|
|
storedCopy, err := os.ReadFile(legacyCopyPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(storedCopy, conflictingCopy) {
|
|
t.Fatal("conflicting preserved source was overwritten")
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyConfigRejectsRepeatedMigrationAfterSuccess(t *testing.T) {
|
|
root := t.TempDir()
|
|
original := writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"domain": "mail.example.com",
|
|
"domains": []string{"mail.example.com"},
|
|
"webDomain": "mail.example.com",
|
|
"sslType": "0",
|
|
"isInit": true,
|
|
"httpsEnabled": 1,
|
|
"httpPort": 80,
|
|
"httpsPort": 443,
|
|
})
|
|
|
|
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rootPath := filepath.Join(root, "config", "config.json")
|
|
migrated, err := os.ReadFile(rootPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bytes.Equal(migrated, original) {
|
|
t.Fatal("successful migration did not replace the legacy root")
|
|
}
|
|
|
|
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); !errors.Is(err, ErrMigrationNotRequired) {
|
|
t.Fatalf("repeated migration error = %v", err)
|
|
}
|
|
after, err := os.ReadFile(rootPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(after, migrated) {
|
|
t.Fatal("repeated migration changed the partitioned root")
|
|
}
|
|
legacyCopy, err := os.ReadFile(filepath.Join(root, "config", legacyMigrationSourceCopy))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(legacyCopy, original) {
|
|
t.Fatal("repeated migration changed the preserved legacy source")
|
|
}
|
|
}
|
|
func TestMigrateLegacyConfigResumesWithEquivalentExistingSecretSection(t *testing.T) {
|
|
root := t.TempDir()
|
|
legacy := map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"domain": "mail.example.com",
|
|
"domains": []string{"mail.example.com"},
|
|
"webDomain": "mail.example.com",
|
|
"sslType": "0",
|
|
"isInit": true,
|
|
"httpsEnabled": 1,
|
|
"httpPort": 80,
|
|
"httpsPort": 443,
|
|
"tgBotToken": "telegram-token",
|
|
"pluginAllowList": []string{"telegram_push"},
|
|
}
|
|
original := writeLegacyMigrationFixture(t, root, legacy)
|
|
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pluginsPath := filepath.Join(root, "config", "plugins.json")
|
|
pluginsBefore, err := os.ReadFile(pluginsPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, name := range []string{"smtp.json", "email.json", "ssl.json"} {
|
|
if err := os.Remove(filepath.Join(root, "config", name)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "config", "config.json"), original, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pluginsAfter, err := os.ReadFile(pluginsPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(pluginsBefore, pluginsAfter) {
|
|
t.Fatal("resume replaced an equivalent existing secret section")
|
|
}
|
|
if _, err := NewConfigLoader(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}).Load(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyConfigRejectsInconsistentExistingSectionBeforeWriting(t *testing.T) {
|
|
root := t.TempDir()
|
|
original := writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"domain": "mail.example.com",
|
|
"domains": []string{"mail.example.com"},
|
|
"webDomain": "mail.example.com",
|
|
"sslType": "0",
|
|
"isInit": true,
|
|
"httpsEnabled": 1,
|
|
"httpPort": 80,
|
|
"httpsPort": 443,
|
|
})
|
|
conflicting := []byte(`{"meta":{"schema":1,"generation":1},"policy":{"smtpPort":2525}}`)
|
|
conflictingPath := filepath.Join(root, "config", "email.json")
|
|
if err := os.WriteFile(conflictingPath, conflicting, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); !errors.Is(err, ErrMigrationTargetExists) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
current, err := os.ReadFile(filepath.Join(root, "config", "config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(current, original) {
|
|
t.Fatal("conflicting migration changed the legacy root configuration")
|
|
}
|
|
stored, err := os.ReadFile(conflictingPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(stored, conflicting) {
|
|
t.Fatal("conflicting section was overwritten")
|
|
}
|
|
for _, name := range []string{"smtp.json", "ssl.json", "plugins.json"} {
|
|
if _, err := os.Stat(filepath.Join(root, "config", name)); !os.IsNotExist(err) {
|
|
t.Fatalf("conflicting migration created %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustMigrationVault(t *testing.T, root string) *credential.Vault {
|
|
t.Helper()
|
|
vault, err := credential.NewVault(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return vault
|
|
}
|
|
|
|
func TestMigrateLegacyConfigMapsPluginAllowListToFixedLogicalIDs(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"domain": "mail.example.com",
|
|
"domains": []string{"mail.example.com"},
|
|
"webDomain": "mail.example.com",
|
|
"sslType": "0",
|
|
"isInit": true,
|
|
"httpsEnabled": 1,
|
|
"httpPort": 80,
|
|
"httpsPort": 443,
|
|
"weChatPushAppId": "app-id",
|
|
"weChatPushSecret": "wechat-secret",
|
|
"weChatPushTemplateId": "template-id",
|
|
"tgBotToken": "telegram-token",
|
|
"pluginAllowList": []string{"spam_block.exe", "wechat_push", "pmail_telegram_push.exe"},
|
|
})
|
|
|
|
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runtime, err := NewConfigLoader(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}).Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, logicalID := range []string{"spam_block", "wechat_push", "telegram_push"} {
|
|
plugin, ok := runtime.Plugins.Plugins[logicalID]
|
|
if !ok || !plugin.Enabled {
|
|
t.Fatalf("plugin %s was not enabled from the legacy allow list: %+v", logicalID, runtime.Plugins.Plugins)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlanLegacyMigrationRejectsUnsafePluginAllowList(t *testing.T) {
|
|
cases := map[string][]string{
|
|
"路径": {"../spam_block"},
|
|
"大小写冲突": {"Spam_Block"},
|
|
"未知插件": {"unknown_plugin"},
|
|
"逻辑重复": {"spam_block", "spam_block.exe"},
|
|
}
|
|
for name, allowList := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"pluginAllowList": allowList,
|
|
})
|
|
plan, err := PlanLegacyMigration(root, RuntimeValidationOptions{})
|
|
if !errors.Is(err, ErrMigrationUnsafeValue) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if len(plan.ManualFields) != 1 || plan.ManualFields[0] != "pluginAllowList" {
|
|
t.Fatalf("manual fields = %+v", plan.ManualFields)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlanLegacyMigrationRejectsEnabledPluginWithoutRequiredCredential(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"pluginAllowList": []string{"wechat_push"},
|
|
})
|
|
plan, err := PlanLegacyMigration(root, RuntimeValidationOptions{})
|
|
if !errors.Is(err, ErrMigrationUnsafeValue) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if len(plan.ManualFields) != 1 || plan.ManualFields[0] != "weChatPushSecret" {
|
|
t.Fatalf("manual fields = %+v", plan.ManualFields)
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyConfigParsesKnownStructuredDatabaseDSNs(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
database string
|
|
dsn string
|
|
wantType DatabaseType
|
|
wantPort uint16
|
|
wantHost string
|
|
wantName string
|
|
password string
|
|
}{
|
|
{
|
|
name: "MySQL",
|
|
database: DBTypeMySQL,
|
|
dsn: "smail:database-secret@tcp(db.example.com:3307)/smail?charset=utf8mb4",
|
|
wantType: DatabaseTypeMySQL,
|
|
wantPort: 3307,
|
|
wantHost: "db.example.com",
|
|
wantName: "smail",
|
|
password: "database-secret",
|
|
},
|
|
{
|
|
name: "PostgreSQL",
|
|
database: DBTypePostgres,
|
|
dsn: "postgres://smail:database-secret@db.example.com:5433/smail",
|
|
wantType: DatabaseTypePostgreSQL,
|
|
wantPort: 5433,
|
|
wantHost: "db.example.com",
|
|
wantName: "smail",
|
|
password: "database-secret",
|
|
},
|
|
}
|
|
for _, testCase := range cases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": testCase.dsn,
|
|
"dbType": testCase.database,
|
|
"domain": "mail.example.com",
|
|
"domains": []string{"mail.example.com"},
|
|
"webDomain": "mail.example.com",
|
|
"sslType": "0",
|
|
"isInit": true,
|
|
"httpsEnabled": 1,
|
|
"httpPort": 80,
|
|
"httpsPort": 443,
|
|
})
|
|
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runtime, err := NewConfigLoader(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}).Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
database := runtime.Root.Database
|
|
if database.Type != testCase.wantType || database.Port != testCase.wantPort || database.Host != testCase.wantHost || database.Name != testCase.wantName || database.Username != "smail" {
|
|
t.Fatalf("database = %+v", database)
|
|
}
|
|
opened, err := OpenStoredSecret(mustMigrationVault(t, root), database.Password)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if opened != testCase.password {
|
|
t.Fatalf("database password = %q", opened)
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(root, "config", "config.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bytes.Contains(data, []byte(testCase.password)) || bytes.Contains(data, []byte(testCase.dsn)) {
|
|
t.Fatal("database credential leaked to the migrated configuration")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlanLegacyMigrationRejectsAmbiguousDatabaseDSN(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "postgres://smail:password@db.example.com:5432/smail?sslmode=disable",
|
|
"dbType": DBTypePostgres,
|
|
})
|
|
plan, err := PlanLegacyMigration(root, RuntimeValidationOptions{})
|
|
if !errors.Is(err, ErrMigrationUnsafeValue) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if len(plan.ManualFields) != 1 || plan.ManualFields[0] != "dbDSN" {
|
|
t.Fatalf("manual fields = %+v", plan.ManualFields)
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyConfigDerivesSetupStateFromFullPreflight(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeLegacyMigrationFixture(t, root, map[string]any{
|
|
"dbDSN": "smail.db",
|
|
"dbType": "sqlite",
|
|
"domain": "mail.example.com",
|
|
"domains": []string{"mail.example.com"},
|
|
"webDomain": "mail.example.com",
|
|
"sslType": "0",
|
|
"isInit": true,
|
|
"httpsEnabled": 1,
|
|
"httpPort": 80,
|
|
"httpsPort": 443,
|
|
})
|
|
options := RuntimeValidationOptions{HasSuperAdmin: false, Production: true}
|
|
if _, err := MigrateLegacyConfig(root, options); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runtime, err := NewConfigLoader(root, ConfigLoaderOptions{Validation: options}).Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if runtime.SetupState != SetupStateDegraded || runtime.Root.SetupState != SetupStateDegraded {
|
|
t.Fatalf("setup state = %q / %q", runtime.SetupState, runtime.Root.SetupState)
|
|
}
|
|
}
|