This commit is contained in:
parent
d2f14ede7a
commit
cfe4401b70
@ -30,7 +30,7 @@ func ValidateConfigAuditQuery(query ConfigAuditQuery) error {
|
||||
if query.Operation != "" && query.Operation != ConfigOperationUpdate && query.Operation != ConfigOperationRollback && query.Operation != ConfigOperationMigration {
|
||||
return ErrInvalidAuditQuery
|
||||
}
|
||||
if query.Section != "" && query.Section != SectionRoot && query.Section != SectionSMTP && query.Section != SectionEmail && query.Section != SectionSSL && query.Section != SectionPlugins {
|
||||
if query.Section != "" && query.Section != SectionRoot && query.Section != SectionMail && query.Section != SectionSMTP && query.Section != SectionEmail && query.Section != SectionSSL && query.Section != SectionPlugins {
|
||||
return ErrInvalidAuditQuery
|
||||
}
|
||||
return nil
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
@ -13,7 +14,7 @@ func TestValidateConfigAuditQueryAcceptsSupportedFilters(t *testing.T) {
|
||||
To: time.Date(2026, 8, 30, 0, 0, 0, 0, time.UTC),
|
||||
ActorID: "42",
|
||||
Operation: ConfigOperationUpdate,
|
||||
Section: SectionSMTP,
|
||||
Section: SectionMail,
|
||||
Limit: 1000,
|
||||
}
|
||||
if err := ValidateConfigAuditQuery(query); err != nil {
|
||||
@ -21,6 +22,25 @@ func TestValidateConfigAuditQueryAcceptsSupportedFilters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigAuditQueryFiltersCombinedMailSection(t *testing.T) {
|
||||
writer := &MemoryAuditWriter{}
|
||||
for _, event := range []ConfigAuditEvent{
|
||||
{Operation: ConfigOperationUpdate, Section: SectionMail, ActorID: "42", ActorRole: "super_admin", FromGeneration: 1, ToGeneration: 2, Result: "success", CreatedAt: time.Now().UTC()},
|
||||
{Operation: ConfigOperationUpdate, Section: SectionSMTP, ActorID: "42", ActorRole: "super_admin", FromGeneration: 2, ToGeneration: 3, Result: "success", CreatedAt: time.Now().UTC()},
|
||||
} {
|
||||
if err := writer.WriteConfigAudit(context.Background(), event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
items, err := writer.Query(context.Background(), ConfigAuditQuery{Section: SectionMail, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Section != SectionMail {
|
||||
t.Fatalf("mail audit filter returned %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigAuditQueryRejectsAmbiguousFilters(t *testing.T) {
|
||||
tests := []ConfigAuditQuery{
|
||||
{Limit: -1},
|
||||
|
||||
@ -199,7 +199,7 @@ func Init() error {
|
||||
if err = json.Unmarshal(cfgData, loaded); err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
applyLegacyLoginPolicy(cfgData, loaded)
|
||||
applyLegacySecurityDefaults(cfgData, loaded)
|
||||
migrated, err := unprotectCredentials(loaded)
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
@ -331,9 +331,12 @@ func ReadConfig() (*Config, error) {
|
||||
|
||||
func readConfig() (*Config, error) {
|
||||
configData := Config{
|
||||
DkimPrivateKeyPath: ROOT_PATH + "config/dkim/dkim.priv",
|
||||
SSLPrivateKeyPath: ROOT_PATH + "config/ssl/private.key",
|
||||
SSLPublicKeyPath: ROOT_PATH + "config/ssl/public.crt",
|
||||
DkimPrivateKeyPath: ROOT_PATH + "config/dkim/dkim.priv",
|
||||
SSLPrivateKeyPath: ROOT_PATH + "config/ssl/private.key",
|
||||
SSLPublicKeyPath: ROOT_PATH + "config/ssl/public.crt",
|
||||
VerificationEnabled: true,
|
||||
ExternalRecoveryEnabled: true,
|
||||
LoginPolicy: "new_device",
|
||||
}
|
||||
cfgData, err := readConfigFile("config.json")
|
||||
if os.IsNotExist(err) {
|
||||
@ -362,7 +365,7 @@ func readConfig() (*Config, error) {
|
||||
log.Error("Read Config Unmarshal Error")
|
||||
return nil, errors.Wrap(err)
|
||||
}
|
||||
applyLegacyLoginPolicy(cfgData, &configData)
|
||||
applyLegacySecurityDefaults(cfgData, &configData)
|
||||
migrated, err := unprotectCredentials(&configData)
|
||||
if err != nil {
|
||||
log.Error("Read Config Credential Error")
|
||||
@ -378,16 +381,34 @@ func readConfig() (*Config, error) {
|
||||
return &configData, nil
|
||||
}
|
||||
|
||||
func applyLegacyLoginPolicy(data []byte, cfg *Config) {
|
||||
if cfg == nil || strings.TrimSpace(cfg.LoginPolicy) != "" {
|
||||
func applyLegacySecurityDefaults(data []byte, cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
var legacy struct {
|
||||
VerificationLoginPolicy string `json:"verificationLoginPolicy"`
|
||||
VerificationEnabled *bool `json:"verificationEnabled"`
|
||||
ExternalRecoveryEnabled *bool `json:"externalRecoveryEnabled"`
|
||||
LoginPolicy *string `json:"loginPolicy"`
|
||||
VerificationLoginPolicy string `json:"verificationLoginPolicy"`
|
||||
}
|
||||
if json.Unmarshal(data, &legacy) == nil {
|
||||
cfg.LoginPolicy = strings.TrimSpace(legacy.VerificationLoginPolicy)
|
||||
if json.Unmarshal(data, &legacy) != nil {
|
||||
return
|
||||
}
|
||||
if legacy.VerificationEnabled == nil {
|
||||
cfg.VerificationEnabled = true
|
||||
}
|
||||
if legacy.ExternalRecoveryEnabled == nil {
|
||||
cfg.ExternalRecoveryEnabled = true
|
||||
}
|
||||
if legacy.LoginPolicy != nil && strings.TrimSpace(*legacy.LoginPolicy) != "" {
|
||||
cfg.LoginPolicy = strings.TrimSpace(*legacy.LoginPolicy)
|
||||
return
|
||||
}
|
||||
if policy := strings.TrimSpace(legacy.VerificationLoginPolicy); policy != "" {
|
||||
cfg.LoginPolicy = policy
|
||||
return
|
||||
}
|
||||
cfg.LoginPolicy = "new_device"
|
||||
}
|
||||
|
||||
func (c *Config) fixPath() {
|
||||
|
||||
@ -744,6 +744,8 @@ func servicesForSection(section Section) []ServiceName {
|
||||
return []ServiceName{ServiceSMTP}
|
||||
case SectionEmail:
|
||||
return []ServiceName{ServiceEmail}
|
||||
case SectionMail:
|
||||
return []ServiceName{ServiceSMTP, ServiceEmail}
|
||||
case SectionSSL:
|
||||
return []ServiceName{ServiceHTTP, ServiceSMTP, ServiceSSL}
|
||||
case SectionPlugins:
|
||||
@ -813,12 +815,38 @@ func applySectionPayload(current RuntimeConfig, section Section, payload json.Ra
|
||||
}
|
||||
value.SMTPSectionConfig.Relay.Password = current.SMTP.Relay.Password
|
||||
candidate.SMTP = value.SMTPSectionConfig
|
||||
candidate.Email.Policy.LoginVerificationPolicy = candidate.SMTP.Relay.LoginPolicy
|
||||
case SectionEmail:
|
||||
var value EmailSectionConfig
|
||||
if err := ParseStrict(payload, &value); err != nil {
|
||||
return RuntimeConfig{}, err
|
||||
}
|
||||
candidate.Email = value
|
||||
candidate.SMTP.Relay.LoginPolicy = candidate.Email.Policy.LoginVerificationPolicy
|
||||
case SectionMail:
|
||||
var value struct {
|
||||
MailSectionConfig
|
||||
RelayPasswordStatus json.RawMessage `json:"relayPasswordStatus"`
|
||||
}
|
||||
if err := ParseStrict(payload, &value); err != nil {
|
||||
return RuntimeConfig{}, err
|
||||
}
|
||||
if value.MailSectionConfig.Relay.Password.Configured() {
|
||||
return RuntimeConfig{}, ErrInvalidSecretChange
|
||||
}
|
||||
value.MailSectionConfig.Relay.Password = current.SMTP.Relay.Password
|
||||
candidate.SMTP = SMTPSectionConfig{
|
||||
Meta: value.Meta,
|
||||
Listener: value.Listener,
|
||||
Relay: value.Relay,
|
||||
}
|
||||
candidate.Email = EmailSectionConfig{
|
||||
Meta: value.Meta,
|
||||
IMAP: value.IMAP,
|
||||
POP3: value.POP3,
|
||||
Policy: value.Policy,
|
||||
}
|
||||
candidate.Email.Policy.LoginVerificationPolicy = candidate.SMTP.Relay.LoginPolicy
|
||||
case SectionSSL:
|
||||
var value SSLSectionConfig
|
||||
if err := ParseStrict(payload, &value); err != nil {
|
||||
@ -901,7 +929,7 @@ func applySecretChanges(candidate *RuntimeConfig, section Section, changes map[s
|
||||
return err
|
||||
}
|
||||
candidate.Root.Database.Password = stored
|
||||
case section == SectionSMTP && name == "relay.password":
|
||||
case (section == SectionSMTP || section == SectionMail) && name == "relay.password":
|
||||
stored, err := ApplySecretChange(vault, candidate.SMTP.Relay.Password, change, SecretPolicy{Required: candidate.SMTP.Relay.Username != "", AllowClear: candidate.SMTP.Relay.Username == "", MaxLength: 4096}, now)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@ -96,6 +96,17 @@ func authorizedConfigActor() AuditActor {
|
||||
return AuditActor{UserID: "1", Role: "super_admin", SessionID: "test-session", RecentAuth: true, RecoveryCodeConfigured: true}
|
||||
}
|
||||
|
||||
func mailSectionFromRuntime(runtime RuntimeConfig) MailSectionConfig {
|
||||
return MailSectionConfig{
|
||||
Meta: runtime.SMTP.Meta,
|
||||
Listener: runtime.SMTP.Listener,
|
||||
Relay: runtime.SMTP.Relay,
|
||||
IMAP: runtime.Email.IMAP,
|
||||
POP3: runtime.Email.POP3,
|
||||
Policy: runtime.Email.Policy,
|
||||
}
|
||||
}
|
||||
|
||||
type rejectingRuntimeSnapshotStore struct {
|
||||
current RuntimeConfig
|
||||
err error
|
||||
@ -372,6 +383,249 @@ func TestConfigManagerUpdateApplyUsesPendingGeneration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManagerMailSectionRejectsNonStrictPayloadsAndDirectSecrets(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeRuntimeFixture(t, root, validRuntimeConfig())
|
||||
manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mail := mailSectionFromRuntime(manager.Snapshot())
|
||||
payload, err := json.Marshal(mail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
unknown := append([]byte(nil), payload[:len(payload)-1]...)
|
||||
unknown = append(unknown, []byte(`,"unknown":true}`)...)
|
||||
if _, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionMail, Payload: unknown}); err == nil {
|
||||
t.Fatal("mail section accepted an unknown JSON field")
|
||||
}
|
||||
|
||||
meta, err := json.Marshal(mail.Meta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate := append([]byte(`{"meta":`), meta...)
|
||||
duplicate = append(duplicate, ',')
|
||||
duplicate = append(duplicate, payload[1:]...)
|
||||
if _, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionMail, Payload: duplicate}); err == nil {
|
||||
t.Fatal("mail section accepted a duplicate JSON field")
|
||||
}
|
||||
|
||||
mail.Relay.Password = StoredSecret{Ciphertext: "enc:v1:direct-secret", Version: credential.CurrentVersion, UpdatedAt: time.Now().UTC()}
|
||||
directSecret, err := json.Marshal(mail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.Validate(context.Background(), ValidateRequest{Section: SectionMail, Payload: directSecret}); !errors.Is(err, ErrInvalidSecretChange) {
|
||||
t.Fatalf("direct relay secret error = %v, want invalid secret change", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManagerMailSectionAppliesBothPartitionsWithoutMailFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runtime := validRuntimeConfig()
|
||||
vault, err := credential.NewVault(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := ApplySecretChange(vault, StoredSecret{}, SecretChange{
|
||||
Operation: SecretReplace,
|
||||
Value: &SecretInput{Plaintext: "existing-relay-secret"},
|
||||
}, SecretPolicy{Required: true, MaxLength: 4096}, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime.SMTP.Relay = SMTPConfig{
|
||||
RelayHost: "smtp.example.com",
|
||||
RelayPort: 587,
|
||||
Username: "relay-user",
|
||||
Password: stored,
|
||||
Security: "starttls",
|
||||
From: "no-reply@example.com",
|
||||
FromDomain: "example.com",
|
||||
VerificationEnabled: true,
|
||||
LoginPolicy: "new_device",
|
||||
}
|
||||
runtime.Email.Policy.LoginVerificationPolicy = "new_device"
|
||||
runtime.Email.Policy.ExternalRecoveryEnabled = true
|
||||
writeRuntimeFixture(t, root, runtime)
|
||||
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
|
||||
manager, err := NewConfigManager(root, options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mail := mailSectionFromRuntime(manager.Snapshot())
|
||||
mail.Relay.Password = StoredSecret{}
|
||||
mail.Listener = SMTPListenerConfig{Port: 2525, STARTTLSPort: 2587, TLSPort: 2465}
|
||||
mail.IMAP.TLSPort = 2993
|
||||
mail.POP3 = POP3ListenerConfig{Port: 2110, TLSPort: 2995}
|
||||
mail.Policy.MaxMessageBytes = 8 << 20
|
||||
mail.Policy.SpamFilterLevel = 2
|
||||
payload, err := json.Marshal(mail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := manager.Update(context.Background(), UpdateRequest{
|
||||
Section: SectionMail, ExpectedGeneration: 1, Payload: payload, Actor: authorizedConfigActor(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Candidate.SMTP.Relay.Password != stored {
|
||||
t.Fatal("mail candidate did not preserve the existing relay secret")
|
||||
}
|
||||
status := manager.Status()
|
||||
if len(status.PendingRestart) != 2 || status.PendingRestart[0] != ServiceSMTP || status.PendingRestart[1] != ServiceEmail {
|
||||
t.Fatalf("mail pending restart services = %+v, want smtp and email", status.PendingRestart)
|
||||
}
|
||||
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, "wrong-candidate", SectionMail, authorizedConfigActor()); !errors.Is(err, ErrGenerationConflict) {
|
||||
t.Fatalf("wrong candidate ID error = %v, want generation conflict", err)
|
||||
}
|
||||
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionSMTP, authorizedConfigActor()); !errors.Is(err, ErrGenerationConflict) {
|
||||
t.Fatalf("wrong section error = %v, want generation conflict", err)
|
||||
}
|
||||
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation+1, updated.CandidateID, SectionMail, authorizedConfigActor()); !errors.Is(err, ErrGenerationConflict) {
|
||||
t.Fatalf("wrong generation error = %v, want generation conflict", err)
|
||||
}
|
||||
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionMail, authorizedConfigActor()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configDirectory := filepath.Join(root, "config")
|
||||
if _, err := os.Stat(filepath.Join(configDirectory, "mail.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("combined mail view was persisted as mail.json: %v", err)
|
||||
}
|
||||
for _, name := range []string{"smtp.json", "email.json"} {
|
||||
data, err := os.ReadFile(filepath.Join(configDirectory, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"generation":2`) {
|
||||
t.Fatalf("%s was not updated to generation 2: %s", name, data)
|
||||
}
|
||||
}
|
||||
reloaded, err := NewConfigLoader(root, options).Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPorts := MailPorts{SMTP: 2525, SMTPSTARTTLS: 2587, SMTPTLS: 2465, IMAP: 2993, POP3: 2110, POP3TLS: 2995}
|
||||
if got := ResolveMailPorts(reloaded.SMTP, reloaded.Email); got != wantPorts {
|
||||
t.Fatalf("reloaded mail ports = %+v, want %+v", got, wantPorts)
|
||||
}
|
||||
if reloaded.Email.Policy.MaxMessageBytes != 8<<20 || reloaded.Email.Policy.SpamFilterLevel != 2 {
|
||||
t.Fatalf("reloaded mail policy is incomplete: %+v", reloaded.Email.Policy)
|
||||
}
|
||||
if reloaded.SMTP.Relay.Password != stored {
|
||||
t.Fatal("reloaded relay secret changed without an explicit secret operation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManagerMailSectionSecretReplacementUsesVault(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeRuntimeFixture(t, root, validRuntimeConfig())
|
||||
manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mail := mailSectionFromRuntime(manager.Snapshot())
|
||||
mail.Relay = SMTPConfig{
|
||||
RelayHost: "smtp.example.com",
|
||||
RelayPort: 587,
|
||||
Username: "relay-user",
|
||||
Security: "starttls",
|
||||
From: "no-reply@example.com",
|
||||
FromDomain: "example.com",
|
||||
}
|
||||
payload, err := json.Marshal(mail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := UpdateRequest{
|
||||
Section: SectionMail, ExpectedGeneration: 1, Payload: payload, Actor: authorizedConfigActor(),
|
||||
SecretChanges: map[string]SecretChange{
|
||||
"relay.password": {Operation: SecretReplace, Value: &SecretInput{Plaintext: "new-relay-secret"}},
|
||||
},
|
||||
}
|
||||
if _, err := manager.Update(context.Background(), request); !errors.Is(err, ErrStepUpRequired) {
|
||||
t.Fatalf("mail secret replacement without step-up error = %v, want step-up required", err)
|
||||
}
|
||||
request.Confirmation = StepUpConfirmation{Confirmed: true, Method: "recent_auth"}
|
||||
updated, err := manager.Update(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored := updated.Candidate.SMTP.Relay.Password
|
||||
if !stored.Configured() || stored.Ciphertext == "new-relay-secret" || !credential.IsEncrypted(stored.Ciphertext) {
|
||||
t.Fatalf("relay secret was not stored as managed ciphertext: %+v", stored)
|
||||
}
|
||||
plaintext, err := manager.vault.Decrypt(stored.Version, stored.Ciphertext)
|
||||
if err != nil || plaintext != "new-relay-secret" {
|
||||
t.Fatalf("relay secret cannot be recovered from the managed vault: value=%q error=%v", plaintext, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigManagerMailSectionPreservesFieldsAcrossPageSpecificEdits(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runtime := validRuntimeConfig()
|
||||
runtime.SMTP.Listener = SMTPListenerConfig{Port: 2525, STARTTLSPort: 2587, TLSPort: 2465}
|
||||
runtime.Email.IMAP.TLSPort = 2993
|
||||
runtime.Email.POP3 = POP3ListenerConfig{Port: 2110, TLSPort: 2995}
|
||||
runtime.Email.Policy.MaxMessageBytes = 4 << 20
|
||||
writeRuntimeFixture(t, root, runtime)
|
||||
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
|
||||
manager, err := NewConfigManager(root, options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
securityEdit := mailSectionFromRuntime(manager.Snapshot())
|
||||
securityEdit.Relay.VerificationEnabled = true
|
||||
securityEdit.Relay.LoginPolicy = "new_device"
|
||||
securityEdit.Policy.ExternalRecoveryEnabled = true
|
||||
securityPayload, err := json.Marshal(securityEdit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := manager.Update(context.Background(), UpdateRequest{
|
||||
Section: SectionMail, ExpectedGeneration: 1, Payload: securityPayload, Actor: authorizedConfigActor(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionMail, authorizedConfigActor()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterSecurity := manager.Snapshot()
|
||||
wantPorts := MailPorts{SMTP: 2525, SMTPSTARTTLS: 2587, SMTPTLS: 2465, IMAP: 2993, POP3: 2110, POP3TLS: 2995}
|
||||
if got := ResolveMailPorts(afterSecurity.SMTP, afterSecurity.Email); got != wantPorts || afterSecurity.Email.Policy.MaxMessageBytes != 4<<20 {
|
||||
t.Fatal("security policy edit overwrote mail listener or policy fields")
|
||||
}
|
||||
|
||||
mailEdit := mailSectionFromRuntime(afterSecurity)
|
||||
mailEdit.Policy.MaxMessageBytes = 12 << 20
|
||||
mailPayload, err := json.Marshal(mailEdit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err = manager.Update(context.Background(), UpdateRequest{
|
||||
Section: SectionMail, ExpectedGeneration: 2, Payload: mailPayload, Actor: authorizedConfigActor(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.ApplyPendingAuthorized(context.Background(), updated.Generation, updated.CandidateID, SectionMail, authorizedConfigActor()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterMail := manager.Snapshot()
|
||||
if !afterMail.SMTP.Relay.VerificationEnabled || afterMail.SMTP.Relay.LoginPolicy != "new_device" ||
|
||||
afterMail.Email.Policy.LoginVerificationPolicy != "new_device" || !afterMail.Email.Policy.ExternalRecoveryEnabled {
|
||||
t.Fatal("mail page edit overwrote security policy fields")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionRecoveryRestoresOldGenerationAfterSectionFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
current := validRuntimeConfig()
|
||||
@ -688,7 +942,13 @@ func TestConfigManagerSecretChangesRequireStepUpAndPreserveStoredSecret(t *testi
|
||||
func TestConfigManagerSecretClearRequiresExplicitConfirmation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
runtime := validRuntimeConfig()
|
||||
runtime.SMTP.Relay = SMTPConfig{}
|
||||
runtime.SMTP.Relay = SMTPConfig{
|
||||
RelayHost: "smtp.example.com",
|
||||
RelayPort: 587,
|
||||
Security: "starttls",
|
||||
From: "no-reply@example.com",
|
||||
FromDomain: "example.com",
|
||||
}
|
||||
writeRuntimeFixture(t, root, runtime)
|
||||
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
|
||||
manager, err := NewConfigManager(root, options)
|
||||
@ -696,6 +956,7 @@ func TestConfigManagerSecretClearRequiresExplicitConfirmation(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
smtp := manager.Snapshot().SMTP
|
||||
smtp.Relay.Username = "relay-user"
|
||||
payload, err := json.Marshal(smtp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -713,6 +974,7 @@ func TestConfigManagerSecretClearRequiresExplicitConfirmation(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
smtp = manager.Snapshot().SMTP
|
||||
smtp.Relay.Username = ""
|
||||
smtp.Relay.Password = StoredSecret{}
|
||||
payload, err = json.Marshal(smtp)
|
||||
if err != nil {
|
||||
@ -1255,7 +1517,7 @@ func TestConfigManagerStatusReportsPendingRestartServices(t *testing.T) {
|
||||
}
|
||||
current := manager.Snapshot()
|
||||
smtp := current.SMTP
|
||||
smtp.Relay.RelayPort++
|
||||
smtp.Listener.Port++
|
||||
payload, err := json.Marshal(smtp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@ -78,8 +78,8 @@ type legacyMigrationConfig struct {
|
||||
SMTPSecurity string `json:"smtpSecurity"`
|
||||
SMTPFrom string `json:"smtpFrom"`
|
||||
SMTPFromDomain string `json:"smtpFromDomain"`
|
||||
VerificationEnabled bool `json:"verificationEnabled"`
|
||||
ExternalRecoveryEnabled bool `json:"externalRecoveryEnabled"`
|
||||
VerificationEnabled *bool `json:"verificationEnabled"`
|
||||
ExternalRecoveryEnabled *bool `json:"externalRecoveryEnabled"`
|
||||
LoginPolicy string `json:"loginPolicy"`
|
||||
VerificationLoginPolicy string `json:"verificationLoginPolicy"`
|
||||
PluginAllowList []string `json:"pluginAllowList"`
|
||||
@ -514,7 +514,7 @@ func legacyRuntimeConfig(root string, legacy legacyMigrationConfig) (RuntimeConf
|
||||
POP3Port: 110,
|
||||
SpamFilterLevel: legacy.SpamFilterLevel,
|
||||
LoginVerificationPolicy: legacyLoginPolicy(legacy),
|
||||
ExternalRecoveryEnabled: legacy.ExternalRecoveryEnabled,
|
||||
ExternalRecoveryEnabled: legacyBoolDefault(legacy.ExternalRecoveryEnabled, true),
|
||||
}},
|
||||
SSL: SSLSectionConfig{Meta: meta, TLS: ssl},
|
||||
Plugins: PluginsSectionConfig{Meta: meta, Plugins: plugins},
|
||||
@ -705,13 +705,13 @@ func legacySMTPConfig(root string, vault *credential.Vault, legacy legacyMigrati
|
||||
Security: legacy.SMTPSecurity,
|
||||
From: legacy.SMTPFrom,
|
||||
FromDomain: legacy.SMTPFromDomain,
|
||||
VerificationEnabled: legacy.VerificationEnabled,
|
||||
VerificationEnabled: legacyBoolDefault(legacy.VerificationEnabled, true),
|
||||
LoginPolicy: legacyLoginPolicy(legacy),
|
||||
}
|
||||
if relay.FromDomain == "" {
|
||||
relay.FromDomain = legacy.Domain
|
||||
}
|
||||
if !relay.VerificationEnabled && relay.RelayHost == "" && relay.RelayPort == 0 && relay.Username == "" && legacy.SMTPPassword == "" {
|
||||
if relay.RelayHost == "" && relay.RelayPort == 0 && relay.Username == "" && legacy.SMTPPassword == "" {
|
||||
return relay, nil, nil
|
||||
}
|
||||
if relay.RelayPort == 0 {
|
||||
@ -871,7 +871,17 @@ func legacyLoginPolicy(legacy legacyMigrationConfig) string {
|
||||
if legacy.LoginPolicy != "" {
|
||||
return legacy.LoginPolicy
|
||||
}
|
||||
return legacy.VerificationLoginPolicy
|
||||
if legacy.VerificationLoginPolicy != "" {
|
||||
return legacy.VerificationLoginPolicy
|
||||
}
|
||||
return "new_device"
|
||||
}
|
||||
|
||||
func legacyBoolDefault(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func legacyControlledReference(value string) (string, bool) {
|
||||
|
||||
@ -100,6 +100,65 @@ func TestMigrateLegacyConfigPreservesBackupAndLoadsPartitions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
|
||||
@ -61,7 +61,7 @@ func runtimeCompatibility(runtime RuntimeConfig) (*Config, error) {
|
||||
DkimPrivateKeyPath: filepath.Join(ROOT_PATH, "config", "dkim", "dkim.priv"),
|
||||
SSLType: SSLTypeUser,
|
||||
SpamFilterLevel: runtime.Email.Policy.SpamFilterLevel,
|
||||
LoginPolicy: runtime.Email.Policy.LoginVerificationPolicy,
|
||||
LoginPolicy: runtime.SMTP.Relay.LoginPolicy,
|
||||
ExternalRecoveryEnabled: runtime.Email.Policy.ExternalRecoveryEnabled,
|
||||
SMTPHost: runtime.SMTP.Relay.RelayHost, SMTPPort: runtime.SMTP.Relay.RelayPort,
|
||||
SMTPUsername: runtime.SMTP.Relay.Username, SMTPSecurity: runtime.SMTP.Relay.Security,
|
||||
|
||||
@ -13,6 +13,7 @@ type Section string
|
||||
|
||||
const (
|
||||
SectionRoot Section = "root"
|
||||
SectionMail Section = "mail"
|
||||
SectionSMTP Section = "smtp"
|
||||
SectionEmail Section = "email"
|
||||
SectionSSL Section = "ssl"
|
||||
@ -66,6 +67,16 @@ type EmailSectionConfig struct {
|
||||
POP3 POP3ListenerConfig `json:"pop3,omitempty"`
|
||||
}
|
||||
|
||||
// MailSectionConfig 是 API 与候选配置使用的组合视图,磁盘仍分别保存 smtp.json 和 email.json。
|
||||
type MailSectionConfig struct {
|
||||
Meta SectionMeta `json:"meta"`
|
||||
Listener SMTPListenerConfig `json:"listener"`
|
||||
Relay SMTPConfig `json:"relay"`
|
||||
IMAP IMAPListenerConfig `json:"imap"`
|
||||
POP3 POP3ListenerConfig `json:"pop3"`
|
||||
Policy EmailConfig `json:"policy"`
|
||||
}
|
||||
|
||||
type SSLSectionConfig struct {
|
||||
Meta SectionMeta `json:"meta"`
|
||||
TLS SSLConfig `json:"tls"`
|
||||
|
||||
@ -117,68 +117,53 @@ func validateSMTP(smtp SMTPConfig) error {
|
||||
if !validLoginVerificationPolicy(smtp.LoginPolicy) {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if smtp.RelayHost != "" && !validSMTPHost(smtp.RelayHost) {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if smtp.RelayPort != 0 && !validPort(smtp.RelayPort) {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if smtp.FromDomain != "" && !validCanonicalDomain(smtp.FromDomain) {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if strings.TrimSpace(smtp.Username) != smtp.Username {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if err := ValidateStoredSecret(smtp.Password, false); err != nil {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if smtp.From != "" {
|
||||
parsedFrom, err := mail.ParseAddress(smtp.From)
|
||||
if err != nil || parsedFrom.Address != smtp.From || strings.ContainsAny(smtp.From, "\r\n") {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
}
|
||||
if !smtp.VerificationEnabled {
|
||||
if smtp.RelayHost != "" && !validSMTPHost(smtp.RelayHost) {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if smtp.RelayPort != 0 && !validPort(smtp.RelayPort) {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if smtp.FromDomain != "" && !validCanonicalDomain(smtp.FromDomain) {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if strings.TrimSpace(smtp.Username) != smtp.Username {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if err := ValidateStoredSecret(smtp.Password, false); err != nil {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if smtp.Username != "" {
|
||||
if err := ValidateStoredSecret(smtp.Password, true); err != nil {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
}
|
||||
if smtp.Security != "" {
|
||||
switch smtp.Security {
|
||||
case "starttls", "implicit_tls", "tls":
|
||||
case "plain":
|
||||
if !isLocalSMTPHost(smtp.RelayHost) {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
default:
|
||||
if smtp.Security != "" {
|
||||
switch smtp.Security {
|
||||
case "starttls", "implicit_tls", "tls":
|
||||
case "plain":
|
||||
if !isLocalSMTPHost(smtp.RelayHost) {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
default:
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
}
|
||||
relayConfigured := smtp.RelayHost != "" || smtp.RelayPort != 0 || smtp.Username != "" ||
|
||||
smtp.Password.Configured() || smtp.Security != "" || smtp.From != ""
|
||||
if !relayConfigured {
|
||||
return nil
|
||||
}
|
||||
if !validSMTPHost(smtp.RelayHost) || !validPort(smtp.RelayPort) || !validCanonicalDomain(smtp.FromDomain) || smtp.From == "" {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if strings.TrimSpace(smtp.Username) != smtp.Username {
|
||||
if smtp.RelayHost == "" || smtp.RelayPort == 0 || smtp.Security == "" || smtp.From == "" || smtp.FromDomain == "" {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
if smtp.Username == "" {
|
||||
if err := ValidateStoredSecret(smtp.Password, false); err != nil || smtp.Password.Configured() {
|
||||
if smtp.Password.Configured() {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
} else if err := ValidateStoredSecret(smtp.Password, true); err != nil {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
switch smtp.Security {
|
||||
case "starttls", "implicit_tls", "tls":
|
||||
case "plain":
|
||||
if !isLocalSMTPHost(smtp.RelayHost) {
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
default:
|
||||
return ErrInvalidSMTP
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -168,6 +168,57 @@ func TestValidateRuntimeConfigRejectsUnsafeCrossSectionValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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: "partially configured relay",
|
||||
smtp: SMTPConfig{
|
||||
RelayHost: "smtp.example.com",
|
||||
RelayPort: 587,
|
||||
Security: "starttls",
|
||||
LoginPolicy: "new_device",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complete relay",
|
||||
smtp: SMTPConfig{
|
||||
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})
|
||||
|
||||
@ -352,6 +352,8 @@ func configSectionFromPath(path string) (config.Section, bool) {
|
||||
switch parts[3] {
|
||||
case "base":
|
||||
return config.SectionRoot, true
|
||||
case "mail":
|
||||
return config.SectionMail, true
|
||||
case "smtp":
|
||||
return config.SectionSMTP, true
|
||||
case "email":
|
||||
@ -461,6 +463,27 @@ func publicConfigSection(runtime config.RuntimeConfig, section config.Section) (
|
||||
},
|
||||
"relayPasswordStatus": publicSecret(runtime.SMTP.Relay.Password),
|
||||
}, nil
|
||||
case config.SectionMail:
|
||||
ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
|
||||
return map[string]any{
|
||||
"meta": runtime.SMTP.Meta,
|
||||
"listener": config.SMTPListenerConfig{Port: ports.SMTP, STARTTLSPort: ports.SMTPSTARTTLS, TLSPort: ports.SMTPTLS},
|
||||
"relay": map[string]any{
|
||||
"relayHost": runtime.SMTP.Relay.RelayHost,
|
||||
"relayPort": runtime.SMTP.Relay.RelayPort,
|
||||
"username": runtime.SMTP.Relay.Username,
|
||||
"security": runtime.SMTP.Relay.Security,
|
||||
"from": runtime.SMTP.Relay.From,
|
||||
"fromDomain": runtime.SMTP.Relay.FromDomain,
|
||||
"verificationEnabled": runtime.SMTP.Relay.VerificationEnabled,
|
||||
"loginPolicy": runtime.SMTP.Relay.LoginPolicy,
|
||||
"password": nil,
|
||||
},
|
||||
"imap": config.IMAPListenerConfig{TLSPort: ports.IMAP},
|
||||
"pop3": config.POP3ListenerConfig{Port: ports.POP3, TLSPort: ports.POP3TLS},
|
||||
"policy": runtime.Email.Policy,
|
||||
"relayPasswordStatus": publicSecret(runtime.SMTP.Relay.Password),
|
||||
}, nil
|
||||
case config.SectionEmail:
|
||||
ports := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
|
||||
value := runtime.Email
|
||||
|
||||
@ -20,6 +20,30 @@ func TestPublicMailPortsMatchListenersWithoutChangingSnapshot(t *testing.T) {
|
||||
}
|
||||
before := runtime.Email
|
||||
want := config.ResolveMailPorts(runtime.SMTP, runtime.Email)
|
||||
mailPublic, err := publicConfigSection(runtime, config.SectionMail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailData, err := json.Marshal(mailPublic)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var writableMail map[string]json.RawMessage
|
||||
if err := json.Unmarshal(mailData, &writableMail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(writableMail, "relayPasswordStatus")
|
||||
mailData, err = json.Marshal(writableMail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mail config.MailSectionConfig
|
||||
if err := config.ParseStrict(mailData, &mail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mail.Listener.Port != want.SMTP || mail.Listener.STARTTLSPort != want.SMTPSTARTTLS || mail.Listener.TLSPort != want.SMTPTLS || mail.IMAP.TLSPort != want.IMAP || mail.POP3.Port != want.POP3 || mail.POP3.TLSPort != want.POP3TLS {
|
||||
t.Fatal("combined public mail ports differ from listener configuration")
|
||||
}
|
||||
smtpPublic, err := publicConfigSection(runtime, config.SectionSMTP)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@ -63,6 +63,10 @@ func TestPublicConfigSectionsDoNotExposeSecretsAndRemainStrictlyDecodable(t *tes
|
||||
var value config.SMTPSectionConfig
|
||||
return config.ParseStrict(data, &value)
|
||||
}},
|
||||
{config.SectionMail, func(data []byte) error {
|
||||
var value config.MailSectionConfig
|
||||
return config.ParseStrict(data, &value)
|
||||
}},
|
||||
{config.SectionEmail, func(data []byte) error {
|
||||
var value config.EmailSectionConfig
|
||||
return config.ParseStrict(data, &value)
|
||||
@ -90,12 +94,24 @@ func TestPublicConfigSectionsDoNotExposeSecretsAndRemainStrictlyDecodable(t *tes
|
||||
if strings.Contains(string(data), "database-secret") || strings.Contains(string(data), "relay-secret") {
|
||||
t.Fatalf("public configuration contains secret data: %s", data)
|
||||
}
|
||||
if strings.Contains(string(data), "enc:v1:") {
|
||||
t.Fatalf("public configuration contains encrypted secret material: %s", data)
|
||||
}
|
||||
writableData := make(map[string]json.RawMessage)
|
||||
if err := json.Unmarshal(data, &writableData); err != nil {
|
||||
t.Fatalf("public configuration is not a JSON object: %v", err)
|
||||
}
|
||||
delete(writableData, "databasePasswordStatus")
|
||||
delete(writableData, "relayPasswordStatus")
|
||||
if test.section == config.SectionMail {
|
||||
var relay map[string]json.RawMessage
|
||||
if err := json.Unmarshal(writableData["relay"], &relay); err != nil {
|
||||
t.Fatalf("mail relay public configuration is not an object: %v", err)
|
||||
}
|
||||
if password, ok := relay["password"]; !ok || string(password) != "null" {
|
||||
t.Fatalf("mail relay password placeholder is invalid: %s", password)
|
||||
}
|
||||
}
|
||||
if test.section == config.SectionPlugins {
|
||||
var plugins map[string]json.RawMessage
|
||||
if err := json.Unmarshal(writableData["plugins"], &plugins); err != nil {
|
||||
|
||||
@ -17,6 +17,7 @@ func Finish() error {
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
initializing := !cfg.IsInit && cfg.SetupState != config.SetupStateReady
|
||||
portsChanged := applyServicePorts(cfg)
|
||||
if cfg.IsSetupReady() {
|
||||
if portsChanged {
|
||||
@ -29,6 +30,11 @@ func Finish() error {
|
||||
}
|
||||
cfg.SetupState = config.SetupStateReady
|
||||
cfg.IsInit = true
|
||||
if initializing {
|
||||
cfg.VerificationEnabled = true
|
||||
cfg.ExternalRecoveryEnabled = true
|
||||
cfg.LoginPolicy = "new_device"
|
||||
}
|
||||
|
||||
err = config.WriteConfig(cfg)
|
||||
if err != nil {
|
||||
|
||||
@ -33,6 +33,33 @@ func TestFinishPersistsServicePorts(t *testing.T) {
|
||||
if loaded.SetupState != config.SetupStateReady || !loaded.IsInit {
|
||||
t.Fatalf("setup state = %q, isInit = %t", loaded.SetupState, loaded.IsInit)
|
||||
}
|
||||
if !loaded.VerificationEnabled || !loaded.ExternalRecoveryEnabled || loaded.LoginPolicy != "new_device" {
|
||||
t.Fatalf("security defaults = verification %t, recovery %t, policy %q", loaded.VerificationEnabled, loaded.ExternalRecoveryEnabled, loaded.LoginPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishPreservesExplicitlyDisabledSecurityOptions(t *testing.T) {
|
||||
prepareFinishConfigTest(t)
|
||||
if err := config.WriteConfig(&config.Config{
|
||||
SetupState: config.SetupStateReady,
|
||||
IsInit: true,
|
||||
VerificationEnabled: false,
|
||||
ExternalRecoveryEnabled: false,
|
||||
LoginPolicy: "off",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Finish(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.ReadConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.VerificationEnabled || loaded.ExternalRecoveryEnabled || loaded.LoginPolicy != "off" {
|
||||
t.Fatalf("explicit security options changed: verification %t, recovery %t, policy %q", loaded.VerificationEnabled, loaded.ExternalRecoveryEnabled, loaded.LoginPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishRepairsReadyConfigWithMissingPorts(t *testing.T) {
|
||||
|
||||
@ -5,9 +5,6 @@
|
||||
<div class="overview-copy">
|
||||
<div class="overview-title">
|
||||
<h3 id="config-page-title">{{ currentSection.label }}</h3>
|
||||
<el-tag v-if="!loading && !availabilityError" :type="stateTagType" effect="light" size="small">
|
||||
<span class="sr-only">运行状态:</span>{{ stateLabel }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-actions">
|
||||
@ -36,15 +33,6 @@
|
||||
</template>
|
||||
</header>
|
||||
|
||||
<el-alert
|
||||
v-if="!canWrite"
|
||||
title="当前账号仅能查看配置,保存、应用和回滚操作已隐藏。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="config-alert"
|
||||
/>
|
||||
|
||||
<div v-if="availabilityError" class="config-availability" role="status">
|
||||
<el-alert :title="availabilityError" type="warning" :closable="false" show-icon />
|
||||
<el-button :loading="loading" @click="refreshPage">重新检查</el-button>
|
||||
@ -52,10 +40,6 @@
|
||||
|
||||
<div v-else-if="!loading" class="config-page-body">
|
||||
<section class="editor-panel" :aria-busy="sectionLoading">
|
||||
<p v-if="selectedSection === 'ssl' && status.tlsMode === 'proxy'" class="heading-note" role="note">
|
||||
当前由反向代理提供 HTTPS,Smail 不自动申请或续期 SSL 证书。下方配置仅用于本地邮件服务证书;外部续期后请重新导入并应用,不能仅更新反代服务器上的证书。
|
||||
</p>
|
||||
|
||||
<el-alert
|
||||
v-if="sectionError"
|
||||
:title="sectionError"
|
||||
@ -65,7 +49,6 @@
|
||||
class="config-alert"
|
||||
/>
|
||||
|
||||
<MailPortSummary v-if="mailPortSummary && !sectionLoading && !sectionError" :services="mailPortSummary" />
|
||||
<ConfigForm
|
||||
v-if="!sectionLoading && !sectionError"
|
||||
:section="selectedSection"
|
||||
@ -74,8 +57,6 @@
|
||||
:disabled="!canWrite || !!working || !!pendingCandidate"
|
||||
@change="updateField"
|
||||
/>
|
||||
<p v-if="sensitiveHint" id="config-sensitive-hint" class="editor-hint">{{ sensitiveHint }}。</p>
|
||||
|
||||
<div v-if="secretField" class="secret-editor">
|
||||
<div>
|
||||
<label for="config-secret-input">{{ secretField.label }}</label>
|
||||
@ -129,7 +110,6 @@
|
||||
{{ savedGeneration ? `应用代次 ${savedGeneration}` : '应用配置' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="action-hint">保存不会立即生效;需要继续修改时先取消候选。跨会话恢复或取消需要近期认证并勾选确认。</p>
|
||||
</div>
|
||||
<div v-if="validationResult" class="result-box" :class="validationResult.valid ? 'result-success' : 'result-error'" role="status">
|
||||
<strong>{{ validationResult.valid ? '验证通过' : '验证未通过' }}</strong>
|
||||
@ -149,16 +129,12 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue'
|
||||
import ConfigForm from '../ConfigForm/ConfigForm.vue'
|
||||
import MailPortSummary from '../MailPortSummary/MailPortSummary.vue'
|
||||
|
||||
const {
|
||||
mailPortSummary,
|
||||
availabilityError,
|
||||
loading,
|
||||
working,
|
||||
status,
|
||||
stateLabel,
|
||||
stateTagType,
|
||||
selectedSection,
|
||||
currentSection,
|
||||
sectionLoading,
|
||||
@ -167,7 +143,6 @@ const {
|
||||
updateField,
|
||||
updateSecret,
|
||||
sectionError,
|
||||
sensitiveHint,
|
||||
secretField,
|
||||
secretValue,
|
||||
confirmed,
|
||||
|
||||
@ -33,16 +33,6 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-group-description {
|
||||
flex: 1 1 100%;
|
||||
margin: 0;
|
||||
color: var(--pm-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.credential-status,
|
||||
.form-group-note,
|
||||
.form-empty {
|
||||
margin: 0 0 8px;
|
||||
color: var(--pm-text-secondary);
|
||||
@ -85,7 +75,7 @@
|
||||
}
|
||||
|
||||
.config-form[data-form-section="base"] .form-grid,
|
||||
.config-form[data-form-section="email"] .form-grid,
|
||||
.config-form[data-form-section="mail"] .form-grid,
|
||||
.form-group[data-group="verification"] .form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@ -108,11 +108,14 @@ const baseGroups = [
|
||||
])
|
||||
]
|
||||
|
||||
const smtpGroups = [
|
||||
group('listener', 'SMTP 服务监听', '', [
|
||||
const mailGroups = [
|
||||
group('listener', '邮件服务监听', '', [
|
||||
port('listener.port', 'SMTP 收信端口', { min: 1, hint: '默认 25,用于接收其他邮件服务器投递,支持 STARTTLS;不是发信中继端口。' }),
|
||||
port('listener.starttlsPort', 'SMTP 提交端口(STARTTLS)', { min: 1, hint: '默认 587,客户端升级 TLS 后进行认证。' }),
|
||||
port('listener.tlsPort', 'SMTP 隐式 TLS 端口', { min: 1, hint: '默认 465,客户端建立连接时直接使用 TLS。' })
|
||||
port('listener.tlsPort', 'SMTP 隐式 TLS 端口', { min: 1, hint: '默认 465,客户端建立连接时直接使用 TLS。' }),
|
||||
port('imap.tlsPort', 'IMAP 隐式 TLS 端口', { min: 1, required: true, hint: '默认 993。客户端建立连接时直接使用 TLS。' }),
|
||||
port('pop3.port', 'POP3 端口(STLS)', { min: 1, required: true, hint: '默认 110,客户端使用 STLS 升级加密后认证。' }),
|
||||
port('pop3.tlsPort', 'POP3 隐式 TLS 端口', { min: 1, required: true, hint: '默认 995,客户端建立连接时直接使用 TLS。' })
|
||||
]),
|
||||
group('relay', 'SMTP 中继连接(可选)', '', [
|
||||
field('relay.relayHost', 'SMTP 服务器', 'text', { placeholder: 'smtp.example.com' }),
|
||||
@ -131,27 +134,17 @@ const smtpGroups = [
|
||||
field('relay.password', '中继密码', 'password', { placeholder: '留空保持原值' }),
|
||||
field('relay.from', '发件邮箱', 'text', { placeholder: 'noreply@example.com' }),
|
||||
field('relay.fromDomain', '发件域名', 'text', { placeholder: 'example.com' })
|
||||
])
|
||||
]
|
||||
|
||||
const imapGroups = [
|
||||
group('imap', 'IMAP 服务监听', '', [
|
||||
port('imap.tlsPort', 'IMAP 隐式 TLS 端口', { min: 1, required: true, hint: '默认 993。当前仅支持隐式 TLS,不提供明文或 STARTTLS 监听。' })
|
||||
])
|
||||
]
|
||||
|
||||
const pop3Groups = [
|
||||
group('pop3', 'POP3 服务监听', '', [
|
||||
port('pop3.port', 'POP3 端口(STLS)', { min: 1, required: true, hint: '默认 110,客户端使用 STLS 升级加密后认证。' }),
|
||||
port('pop3.tlsPort', 'POP3 隐式 TLS 端口', { min: 1, required: true, hint: '默认 995,客户端建立连接时直接使用 TLS。' })
|
||||
])
|
||||
]
|
||||
|
||||
const emailGroups = [
|
||||
group('policy', '邮件与账号策略', '', [
|
||||
]),
|
||||
group('policy', '邮件策略', '', [
|
||||
field('policy.maxMessageBytes', '最大邮件大小(字节)', 'number', { min: 0, max: Number.MAX_SAFE_INTEGER, integer: true, hint: '0 使用后端默认限制;1 MiB = 1048576 字节。' }),
|
||||
field('policy.spamFilterLevel', '垃圾邮件过滤级别', 'select', { fallback: 0, options: options([[0, '不过滤'], [1, 'SPF 与 DKIM 均失败时过滤'], [2, 'SPF 失败时过滤'], [3, 'DKIM 失败时过滤']]) }),
|
||||
field('policy.loginVerificationPolicy', '账号登录验证策略', 'select', { options: loginOptions }),
|
||||
field('policy.spamFilterLevel', '垃圾邮件过滤级别', 'select', { fallback: 0, options: options([[0, '不过滤'], [1, 'SPF 与 DKIM 均失败时过滤'], [2, 'SPF 失败时过滤'], [3, 'DKIM 失败时过滤']]) })
|
||||
])
|
||||
]
|
||||
|
||||
const securityPolicyGroups = [
|
||||
group('verification', '登录与账号恢复', '', [
|
||||
field('relay.verificationEnabled', '启用账号登录验证', 'switch'),
|
||||
field('relay.loginPolicy', '账号登录验证策略', 'select', { options: loginOptions }),
|
||||
field('policy.externalRecoveryEnabled', '允许外部邮箱恢复账号', 'switch')
|
||||
])
|
||||
]
|
||||
@ -190,7 +183,7 @@ const pluginDefinitions = {
|
||||
export function getFieldGroups(section, config) {
|
||||
const pluginId = Object.hasOwn(pluginDefinitions, section) ? section : ''
|
||||
if (section !== 'plugins' && !pluginId) {
|
||||
return ({ base: baseGroups, smtp: smtpGroups, imap: imapGroups, pop3: pop3Groups, email: emailGroups, ssl: sslGroups }[section] || [])
|
||||
return ({ base: baseGroups, mail: mailGroups, security_policy: securityPolicyGroups, ssl: sslGroups }[section] || [])
|
||||
.map(item => ({
|
||||
...item,
|
||||
fields: item.fields
|
||||
@ -207,14 +200,14 @@ export function getFieldGroups(section, config) {
|
||||
return {
|
||||
id,
|
||||
title: definition?.label || `插件:${id}`,
|
||||
description: `插件标识:${id}。程序与权限由安装目录维护;凭据请在专用插件设置中管理。`,
|
||||
description: '',
|
||||
fields: [
|
||||
input('enabled', '启用插件', 'switch', { locked: !definition }),
|
||||
input('executable', '运行程序', 'text', { locked: true }),
|
||||
{ path: [...prefix, 'permissions', 'network'], label: '允许访问的网络目标', type: 'network', hint: '仅允许 HTTPS 或 WSS;填写域名和端口,不接受 IP 地址。', locked: !definition },
|
||||
...(definition && settingsEditable ? definition.fields.map(item => ({ ...item, path: [...prefix, 'settings', ...item.path] })) : [])
|
||||
],
|
||||
note: !settingsEditable ? '当前插件使用非标准设置格式,已保留原值;请在专用插件页面调整。' : '',
|
||||
note: '',
|
||||
secretConfigured: !!plugin.secretStatus?.configured
|
||||
}
|
||||
})
|
||||
@ -291,22 +284,24 @@ export function validateFormConfig(section, config) {
|
||||
errors['http.httpsPort'] = 'HTTP 与 HTTPS 监听端口不能相同'
|
||||
}
|
||||
}
|
||||
if (section === 'smtp') {
|
||||
if (section === 'mail') {
|
||||
const relay = config.relay || {}
|
||||
if (relay.relayHost && relay.security) {
|
||||
if (!['starttls', 'implicit_tls', 'tls', 'plain'].includes(relay.security)) {
|
||||
errors['relay.security'] = '请选择明确的连接加密方式,系统会自动匹配端口'
|
||||
}
|
||||
const relayConfigured = !!(
|
||||
relay.relayHost || relay.relayPort || relay.username || relay.password || relay.security || relay.from
|
||||
)
|
||||
if (relayConfigured) {
|
||||
if (!relay.relayHost) errors['relay.relayHost'] = '请填写 SMTP 服务器'
|
||||
if (!['starttls', 'implicit_tls', 'tls', 'plain'].includes(relay.security)) errors['relay.security'] = '请选择连接加密方式'
|
||||
if (!validPort(relay.relayPort)) errors['relay.relayPort'] = '请填写 1~65535 的 SMTP 端口'
|
||||
if (!relay.from) errors['relay.from'] = '请填写发件邮箱'
|
||||
if (!relay.fromDomain) errors['relay.fromDomain'] = '请填写发件域名'
|
||||
}
|
||||
if (relay.security === 'plain' && !localSMTPHost(relay.relayHost)) {
|
||||
errors['relay.relayHost'] = '不加密连接仅限本机地址;远程服务器请选择 STARTTLS 或隐式 TLS'
|
||||
}
|
||||
}
|
||||
if (['smtp', 'imap', 'pop3'].includes(section)) {
|
||||
const paths = section === 'smtp'
|
||||
? ['listener.port', 'listener.starttlsPort', 'listener.tlsPort']
|
||||
: ['imap.tlsPort', 'pop3.port', 'pop3.tlsPort']
|
||||
if (section === 'mail') {
|
||||
const paths = ['listener.port', 'listener.starttlsPort', 'listener.tlsPort', 'imap.tlsPort', 'pop3.port', 'pop3.tlsPort']
|
||||
for (const path of paths) {
|
||||
const value = getValue(config, path.split('.'))
|
||||
if (value > 0 && paths.some(other => other !== path && getValue(config, other.split('.')) === value)) errors[path] = '邮件服务监听端口不能重复'
|
||||
|
||||
@ -10,11 +10,8 @@
|
||||
>
|
||||
<div class="form-group-heading">
|
||||
<h4>{{ group.title }}</h4>
|
||||
<p v-if="group.description" class="form-group-description">{{ group.description }}</p>
|
||||
<span v-if="group.fields.some(field => errors[fieldKey(field)])" class="section-error">待修正</span>
|
||||
</div>
|
||||
<p v-if="group.secretConfigured !== undefined" class="credential-status">{{ group.secretConfigured ? '凭据已配置' : '未配置凭据' }}</p>
|
||||
<p v-if="group.note" class="form-group-note" role="note">{{ group.note }}</p>
|
||||
<div class="form-grid" :class="{ 'site-grid': section === 'base' && group.id === 'site' }">
|
||||
<ConfigField
|
||||
v-for="field in group.fields"
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
|
||||
import BaseConfig from './BaseConfig/BaseConfig.vue'
|
||||
import SmtpConfig from './SmtpConfig/SmtpConfig.vue'
|
||||
import ImapConfig from './ImapConfig/ImapConfig.vue'
|
||||
import Pop3Config from './Pop3Config/Pop3Config.vue'
|
||||
import EmailConfig from './EmailConfig/EmailConfig.vue'
|
||||
import SslConfig from './SslConfig/SslConfig.vue'
|
||||
import PluginsConfig from './PluginsConfig/PluginsConfig.vue'
|
||||
@ -17,10 +14,7 @@ export function useNavigation() {
|
||||
const switching = ref(false)
|
||||
const pages = [
|
||||
{ value: 'base', label: '基础配置', component: BaseConfig },
|
||||
{ value: 'smtp', label: 'SMTP 配置', component: SmtpConfig },
|
||||
{ value: 'imap', label: 'IMAP 配置', component: ImapConfig },
|
||||
{ value: 'pop3', label: 'POP3 配置', component: Pop3Config },
|
||||
{ value: 'email', label: '邮箱策略', component: EmailConfig },
|
||||
{ value: 'mail', label: '邮件服务与策略', component: EmailConfig },
|
||||
{ value: 'ssl', label: 'SSL 配置', component: SslConfig },
|
||||
{ value: 'plugins', label: '插件配置', children: [
|
||||
{ value: 'spam_block', label: '垃圾邮件拦截', component: PluginsConfig },
|
||||
|
||||
@ -5,16 +5,14 @@ import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
|
||||
import { readFormConfig, updateFormConfig, validateFormConfig } from './ConfigForm/ConfigForm.js'
|
||||
|
||||
const sections = [
|
||||
{ value: 'base', label: '基础配置', description: '管理站点与数据库连接,敏感凭据单独填写。' },
|
||||
{ value: 'smtp', label: 'SMTP 配置', description: '管理本机监听与发信中继;监听端口变更需应用配置并重启后端。' },
|
||||
{ value: 'imap', label: 'IMAP 配置', description: '管理 IMAP 隐式 TLS 监听;端口变更需应用配置并重启后端。' },
|
||||
{ value: 'pop3', label: 'POP3 配置', description: '管理 POP3 的 STLS 与隐式 TLS 监听;端口变更需应用配置并重启后端。' },
|
||||
{ value: 'email', label: '邮箱策略', description: '自动汇总三个协议的配置端口,管理邮件与账号恢复策略。' },
|
||||
{ value: 'ssl', label: 'SSL 配置', description: '管理证书与加密连接,应用后请留意待重启提示。' },
|
||||
{ value: 'plugins', label: '插件配置', description: '管理插件运行配置,敏感设置仅显示配置状态。' },
|
||||
{ value: 'spam_block', label: '垃圾邮件拦截', description: '配置垃圾邮件检测服务、超时和拦截阈值,仅编辑当前插件。' },
|
||||
{ value: 'telegram_push', label: 'Telegram 推送', description: '配置 Telegram 推送的运行参数与网络权限,个人绑定在扩展功能中管理。' },
|
||||
{ value: 'wechat_push', label: '微信推送', description: '配置微信推送的应用、模板与接收用户,敏感凭据不回填。' }
|
||||
{ value: 'base', label: '基础配置' },
|
||||
{ value: 'mail', label: '邮件服务与策略' },
|
||||
{ value: 'security_policy', label: '安全策略' },
|
||||
{ value: 'ssl', label: 'SSL 配置' },
|
||||
{ value: 'plugins', label: '插件配置' },
|
||||
{ value: 'spam_block', label: '垃圾邮件拦截' },
|
||||
{ value: 'telegram_push', label: 'Telegram 推送' },
|
||||
{ value: 'wechat_push', label: '微信推送' }
|
||||
]
|
||||
|
||||
const serviceLabels = {
|
||||
@ -57,7 +55,7 @@ export function usePage(pageKey = 'base') {
|
||||
// 独立页面复用原配置分区的原子保存与候选保护,不丢弃其他页面的字段。
|
||||
const pluginPages = ['spam_block', 'telegram_push', 'wechat_push']
|
||||
const apiSection = () => pluginPages.includes(selectedSection.value) ? 'plugins'
|
||||
: ['imap', 'pop3'].includes(selectedSection.value) ? 'email' : selectedSection.value
|
||||
: selectedSection.value === 'security_policy' ? 'mail' : selectedSection.value
|
||||
const currentSection = computed(() => sections.find(item => item.value === selectedSection.value) || sections[0])
|
||||
const sectionLoading = ref(false)
|
||||
let sectionRequestId = 0
|
||||
@ -66,23 +64,6 @@ export function usePage(pageKey = 'base') {
|
||||
const formConfig = computed(() => readFormConfig(editorText.value))
|
||||
const formErrors = ref({})
|
||||
const loadedText = ref('')
|
||||
const smtpListener = ref(null)
|
||||
const mailPortSummary = computed(() => {
|
||||
if (selectedSection.value !== 'email' || !loadedText.value || !smtpListener.value) return null
|
||||
const email = readFormConfig(loadedText.value)
|
||||
return [
|
||||
{ name: 'SMTP', ports: [
|
||||
{ label: '收信 / STARTTLS', value: smtpListener.value.port },
|
||||
{ label: '提交 / STARTTLS', value: smtpListener.value.starttlsPort },
|
||||
{ label: '隐式 TLS', value: smtpListener.value.tlsPort }
|
||||
] },
|
||||
{ name: 'IMAP', ports: [{ label: '隐式 TLS', value: email.imap?.tlsPort }] },
|
||||
{ name: 'POP3', ports: [
|
||||
{ label: 'STLS', value: email.pop3?.port },
|
||||
{ label: '隐式 TLS', value: email.pop3?.tlsPort }
|
||||
] }
|
||||
]
|
||||
})
|
||||
const sectionError = ref('')
|
||||
const secretValue = ref('')
|
||||
const confirmed = ref(false)
|
||||
@ -102,22 +83,24 @@ export function usePage(pageKey = 'base') {
|
||||
: '候选内容尚未完整读取,请刷新后核对。'
|
||||
return `${scope}三个插件共用一个配置分区,请在对应三级菜单核对;应用时整个候选一起生效,其他插件的原有配置会保留。`
|
||||
}
|
||||
if (pending?.section !== 'email' || apiSection() !== 'email') return ''
|
||||
if (pending?.section !== 'mail' || apiSection() !== 'mail') return ''
|
||||
const active = readFormConfig(loadedText.value)
|
||||
const candidate = pending.payload || {}
|
||||
const changed = [
|
||||
['imap', 'IMAP 配置'],
|
||||
['pop3', 'POP3 配置'],
|
||||
['policy', '邮箱策略']
|
||||
['listener', 'SMTP 监听'],
|
||||
['imap', 'IMAP 监听'],
|
||||
['pop3', 'POP3 监听'],
|
||||
['relay', 'SMTP 中继与登录验证'],
|
||||
['policy', '邮件与账号策略']
|
||||
].filter(([key]) => {
|
||||
const before = active[key] || {}
|
||||
const after = candidate[key] || {}
|
||||
return Object.keys({ ...before, ...after }).some(field => JSON.stringify(before[field]) !== JSON.stringify(after[field]))
|
||||
}).map(([, label]) => label)
|
||||
const scope = pending.payload && loadedText.value
|
||||
? (changed.length ? `本次候选涉及:${changed.join('、')}。` : '本次候选的协议端口和邮箱策略未变化。')
|
||||
? (changed.length ? `本次候选涉及:${changed.join('、')}。` : '本次候选的邮件服务与安全策略未变化。')
|
||||
: '候选内容尚未完整读取,请刷新后核对。'
|
||||
return `${scope}IMAP、POP3 和邮箱策略共用一个配置分区,请逐页核对;应用时会一起生效,监听端口仍需重启后端。`
|
||||
return `${scope}邮件服务页和安全策略页共用一个候选配置;应用时全部变更一起生效,监听端口变更仍需重启后端。`
|
||||
})
|
||||
let savedInput = null
|
||||
const validationResult = ref(null)
|
||||
@ -294,7 +277,6 @@ export function usePage(pageKey = 'base') {
|
||||
formErrors.value = {}
|
||||
editorText.value = ''
|
||||
loadedText.value = ''
|
||||
smtpListener.value = null
|
||||
secretValue.value = ''
|
||||
confirmed.value = false
|
||||
validationResult.value = null
|
||||
@ -305,16 +287,6 @@ export function usePage(pageKey = 'base') {
|
||||
if (requestId !== sectionRequestId) return
|
||||
notifyResponseError(response, '配置分区读取失败')
|
||||
const publicConfig = response.data || {}
|
||||
if (selectedSection.value === 'email') {
|
||||
const smtpResponse = await http.get('/api/v1/config/smtp')
|
||||
if (requestId !== sectionRequestId) return
|
||||
notifyResponseError(smtpResponse, 'SMTP 监听配置读取失败')
|
||||
// 不拼接不同代次的配置,也不在读取失败时显示旧端口。
|
||||
if (smtpResponse.data?.meta?.generation !== publicConfig.meta?.generation) {
|
||||
throw new Error('配置代次已变化,请刷新后查看最新端口')
|
||||
}
|
||||
smtpListener.value = smtpResponse.data?.listener || null
|
||||
}
|
||||
editorText.value = JSON.stringify(publicConfig, null, 2)
|
||||
loadedText.value = editorText.value
|
||||
if (section === 'base') {
|
||||
@ -519,7 +491,7 @@ export function usePage(pageKey = 'base') {
|
||||
if (selectedSection.value === 'base') {
|
||||
delete payload.databasePasswordStatus
|
||||
}
|
||||
if (selectedSection.value === 'smtp') {
|
||||
if (apiSection() === 'mail') {
|
||||
delete payload.relayPasswordStatus
|
||||
if (payload.relay && 'password' in payload.relay) {
|
||||
delete payload.relay.password
|
||||
@ -549,7 +521,7 @@ export function usePage(pageKey = 'base') {
|
||||
secretChanges['database.password'] = { operation: 'replace', value: { plaintext: value } }
|
||||
}
|
||||
const relayPassword = formConfig.value?.relay?.password
|
||||
if (relayPassword && selectedSection.value === 'smtp') {
|
||||
if (relayPassword && selectedSection.value === 'mail') {
|
||||
secretChanges['relay.password'] = { operation: 'replace', value: { plaintext: relayPassword } }
|
||||
}
|
||||
return {
|
||||
@ -737,7 +709,6 @@ export function usePage(pageKey = 'base') {
|
||||
})
|
||||
|
||||
return {
|
||||
mailPortSummary,
|
||||
pendingScopeHint,
|
||||
loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections,
|
||||
selectedSection, currentSection, sectionLoading, editorText, formConfig, formErrors, updateField, updateSecret,
|
||||
|
||||
@ -7,7 +7,7 @@ import { provide } from 'vue'
|
||||
import { usePage } from '../ConfigSettings.js'
|
||||
import ConfigEditor from '../ConfigEditor/ConfigEditor.vue'
|
||||
|
||||
const page = usePage('email')
|
||||
const page = usePage('mail')
|
||||
provide('smail-config-page', page)
|
||||
defineExpose({ beforeLeave: page.beforeLeave })
|
||||
</script>
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
.global-security-policy {
|
||||
min-width: 0;
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export const globalSecurityPolicySection = 'security_policy'
|
||||
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="global-security-policy">
|
||||
<ConfigEditor />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { provide } from 'vue'
|
||||
import { usePage } from '../../ConfigSettings/ConfigSettings.js'
|
||||
import ConfigEditor from '../../ConfigSettings/ConfigEditor/ConfigEditor.vue'
|
||||
|
||||
const page = usePage('security_policy')
|
||||
provide('smail-config-page', page)
|
||||
defineExpose({ beforeLeave: page.beforeLeave })
|
||||
</script>
|
||||
|
||||
<style scoped src="./GlobalSecurityPolicy.css"></style>
|
||||
@ -12,8 +12,12 @@ export function usePage() {
|
||||
const isSuperAdmin = computed(() => globalStatus.role === 'super_admin')
|
||||
const supported = webAuthnSupported()
|
||||
const webAuthnEnabled = computed(() => globalStatus.userInfos.webAuthnEnabled === true)
|
||||
const canReadGlobalPolicy = computed(() => globalStatus.hasCapability('config.read'))
|
||||
const sectionOptions = computed(() => {
|
||||
const options = []
|
||||
if (canReadGlobalPolicy.value) {
|
||||
options.push({ value: 'security-policy', label: '安全策略' })
|
||||
}
|
||||
if (!isSuperAdmin.value) {
|
||||
options.push({ value: 'password', label: lang.modify_pwd })
|
||||
}
|
||||
@ -31,10 +35,11 @@ export function usePage() {
|
||||
set: value => { globalStatus.securityActiveSection = value }
|
||||
})
|
||||
|
||||
const selectSection = value => {
|
||||
if (sectionOptions.value.some(item => item.value === value)) {
|
||||
selectedSection.value = value
|
||||
}
|
||||
const globalSecurityPolicyPage = ref(null)
|
||||
const selectSection = async value => {
|
||||
if (!sectionOptions.value.some(item => item.value === value) || value === selectedSection.value) return
|
||||
if (selectedSection.value === 'security-policy' && !await (globalSecurityPolicyPage.value?.beforeLeave?.() ?? true)) return
|
||||
selectedSection.value = value
|
||||
}
|
||||
|
||||
watch(sectionOptions, options => {
|
||||
@ -290,6 +295,7 @@ export function usePage() {
|
||||
|
||||
return {
|
||||
lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection,
|
||||
globalSecurityPolicyPage,
|
||||
registering, credentialsLoading, credentials, securityLoading,
|
||||
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalLoading,
|
||||
bindForm, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,
|
||||
|
||||
@ -9,6 +9,11 @@
|
||||
/>
|
||||
|
||||
<div class="security-main">
|
||||
<GlobalSecurityPolicy
|
||||
v-if="selectedSection === 'security-policy'"
|
||||
ref="globalSecurityPolicyPage"
|
||||
/>
|
||||
|
||||
<section v-if="selectedSection === 'password' && !isSuperAdmin" class="security-section">
|
||||
<div class="settings-header">
|
||||
<h3>{{ lang.modify_pwd }}</h3>
|
||||
@ -95,9 +100,11 @@
|
||||
<script setup>
|
||||
import { usePage } from './SecuritySettings.js'
|
||||
import SettingsSubmenu from '../SettingsSubmenu/SettingsSubmenu.vue'
|
||||
import GlobalSecurityPolicy from './GlobalSecurityPolicy/GlobalSecurityPolicy.vue'
|
||||
|
||||
const {
|
||||
lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection,
|
||||
globalSecurityPolicyPage,
|
||||
registering, credentialsLoading, credentials, securityLoading,
|
||||
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalLoading,
|
||||
bindForm, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,
|
||||
|
||||
@ -33,8 +33,14 @@ func TestConfiguredMailListenerPortsAcceptProtocolConnections(t *testing.T) {
|
||||
smtp := config.SMTPSectionConfig{Meta: meta, Listener: config.SMTPListenerConfig{
|
||||
Port: ports[0], STARTTLSPort: ports[1], TLSPort: ports[2],
|
||||
}}
|
||||
// 中继与 IMAP 共用数值端口也不冲突;不配置中继主机仍可启动全部本地监听。
|
||||
smtp.Relay.RelayPort = ports[3]
|
||||
// 中继与 IMAP 共用数值端口也不冲突;中继是外部连接,不参与本地监听端口占用。
|
||||
smtp.Relay = config.SMTPConfig{
|
||||
RelayHost: "127.0.0.1",
|
||||
RelayPort: ports[3],
|
||||
Security: "plain",
|
||||
From: "no-reply@example.com",
|
||||
FromDomain: "example.com",
|
||||
}
|
||||
email := config.EmailSectionConfig{Meta: meta, IMAP: config.IMAPListenerConfig{TLSPort: ports[3]}, POP3: config.POP3ListenerConfig{Port: ports[4], TLSPort: ports[5]}}
|
||||
ssl := config.SSLSectionConfig{Meta: meta, TLS: config.SSLConfig{Mode: "default", DefaultDirectory: "config/ssl"}}
|
||||
rootConfig := config.RootConfig{
|
||||
|
||||
@ -22,7 +22,7 @@ test('基础配置不再展示冗余安全选项与开关,通行密钥由系
|
||||
assert.equal(updated.security.webAuthnRpId, 'mail.old.com')
|
||||
})
|
||||
|
||||
test('七个页面使用分组表单,控件和样式能编译且不再提供 JSON 编辑器', () => {
|
||||
test('配置与安全策略页面使用分组表单,控件和样式能编译且不再提供 JSON 编辑器', () => {
|
||||
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
|
||||
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
|
||||
for (const name of ['ConfigForm', 'ConfigField', 'ConfigEditor']) {
|
||||
@ -34,8 +34,13 @@ test('七个页面使用分组表单,控件和样式能编译且不再提供 J
|
||||
assert.deepEqual(compileStyle({ source: css, id: `data-v-${name}`, scoped: true }).errors, [])
|
||||
assert.doesNotMatch(template, /&#x[0-9a-f]+;|type="textarea"|配置 JSON 编辑器/i)
|
||||
}
|
||||
for (const section of ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins']) {
|
||||
const groups = getFieldGroups(section, { plugins: { spam_block: { settings: null } } })
|
||||
const config = { plugins: {
|
||||
spam_block: { settings: null },
|
||||
telegram_push: { settings: null },
|
||||
wechat_push: { settings: null }
|
||||
} }
|
||||
for (const section of ['base', 'mail', 'security_policy', 'ssl', 'plugins', 'spam_block', 'telegram_push', 'wechat_push']) {
|
||||
const groups = getFieldGroups(section, config)
|
||||
assert.ok(groups.length)
|
||||
const keys = groups.flatMap(group => group.fields.map(field => field.path.join('.')))
|
||||
assert.equal(keys.length, new Set(keys).size)
|
||||
@ -44,17 +49,26 @@ test('七个页面使用分组表单,控件和样式能编译且不再提供 J
|
||||
}
|
||||
})
|
||||
|
||||
test('各协议仅编辑自己的监听端口,邮箱策略没有重复端口输入项', () => {
|
||||
test('邮件服务页面统一编辑六个监听端口,中继和策略字段保持在同一配置分区', () => {
|
||||
const config = { listener: { port: 25, starttlsPort: 587, tlsPort: 465 }, relay: { relayPort: 465 }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } }
|
||||
const text = JSON.stringify(config)
|
||||
assert.equal(updateFormConfig(text, 'email', ['policy', 'smtpPort'], 2525), text)
|
||||
assert.equal(updateFormConfig(text, 'imap', ['pop3', 'port'], 1110), text)
|
||||
assert.equal(updateFormConfig(text, 'pop3', ['imap', 'tlsPort'], 1993), text)
|
||||
const edited = readFormConfig(updateFormConfig(text, 'smtp', ['listener', 'tlsPort'], 2465))
|
||||
const groups = getFieldGroups('mail', config)
|
||||
const keys = groups.flatMap(group => group.fields.map(input => input.path.join('.')))
|
||||
assert.deepEqual(keys.filter(key => /^(listener|imap|pop3)\./.test(key)), [
|
||||
'listener.port', 'listener.starttlsPort', 'listener.tlsPort',
|
||||
'imap.tlsPort', 'pop3.port', 'pop3.tlsPort'
|
||||
])
|
||||
assert.ok(keys.includes('relay.relayHost'))
|
||||
assert.ok(keys.includes('relay.password'))
|
||||
assert.ok(keys.includes('policy.maxMessageBytes'))
|
||||
assert.ok(keys.includes('policy.spamFilterLevel'))
|
||||
assert.ok(!keys.includes('policy.smtpPort'))
|
||||
assert.ok(!keys.includes('policy.imapPort'))
|
||||
assert.ok(!keys.includes('policy.pop3Port'))
|
||||
const edited = readFormConfig(updateFormConfig(text, 'mail', ['listener', 'tlsPort'], 2465))
|
||||
assert.equal(edited.listener.tlsPort, 2465)
|
||||
assert.equal(edited.relay.relayPort, 465)
|
||||
assert.deepEqual(getFieldGroups('email', config).flatMap(group => group.fields).filter(field => /port/i.test(field.path.join('.'))), [])
|
||||
assert.ok(validateFormConfig('smtp', { ...config, listener: { ...config.listener, tlsPort: 587 } })['listener.tlsPort'])
|
||||
assert.ok(validateFormConfig('mail', { ...config, listener: { ...config.listener, tlsPort: 587 } })['listener.tlsPort'])
|
||||
})
|
||||
|
||||
test('三个插件页面只展示和编辑自身字段,完整保留其他插件和未知字段', () => {
|
||||
@ -119,22 +133,22 @@ test('文本输入的左右留白属于原生输入框,不影响数字、选
|
||||
assert.deepEqual(rules.get('.field-text-input[data-v-caret-test] .el-input__inner'), { 'padding-inline': '11px' })
|
||||
})
|
||||
|
||||
test('分组不显示重复标题和介绍,保留无障碍名称、凭据状态及异常提示', () => {
|
||||
test('分组只显示必要标题和字段错误,删除普通介绍、备注和凭据状态', () => {
|
||||
const template = readFileSync(new URL('ConfigForm/ConfigForm.vue', root), 'utf8')
|
||||
const css = readFileSync(new URL('ConfigForm/ConfigForm.css', root), 'utf8')
|
||||
assert.match(template, /<h4>{{ group.title }}<\/h4>/)
|
||||
assert.match(template, /form-group-heading|form-group-description|group\.description/)
|
||||
assert.match(template, /form-group-heading/)
|
||||
assert.doesNotMatch(template, /form-group-description|group\.description/)
|
||||
assert.doesNotMatch(template, /aria-labelledby/)
|
||||
assert.match(template, /<section\b[^>]*:aria-label="group.title"/)
|
||||
assert.match(template, /v-if="group.secretConfigured !== undefined" class="credential-status"/)
|
||||
assert.match(template, /group.secretConfigured \? '凭据已配置' : '未配置凭据'/)
|
||||
assert.match(template, /v-if="group.note" class="form-group-note" role="note"/)
|
||||
assert.doesNotMatch(template, /credential-status|secretConfigured|form-group-note|group\.note/)
|
||||
assert.match(template, /v-if="!groups.length" class="form-empty"/)
|
||||
assert.match(template, /<ConfigField\b/)
|
||||
for (const binding of ['v-for="field in group.fields"', ':value="getValue(config, field.path)"', ':error="errors[fieldKey(field)]"', ':disabled="disabled"', "emit('change', path, value)"]) {
|
||||
assert.ok(template.includes(binding))
|
||||
}
|
||||
assert.match(css, /form-group-heading|form-group-description/)
|
||||
assert.match(css, /form-group-heading/)
|
||||
assert.doesNotMatch(css, /form-group-description|credential-status|form-group-note/)
|
||||
const formStyle = css.match(/\.config-form\s*\{([^}]+)\}/)?.[1] || ''
|
||||
assert.doesNotMatch(formStyle, /margin-top|padding-top/)
|
||||
assert.match(css, /\.config-form:not\(:first-child\)\s*\{\s*margin-top: 10px;/)
|
||||
@ -211,10 +225,13 @@ test('开关、数字、空数字和列表保持真实类型,代理 HTTPS 正
|
||||
assert.equal(readFormConfig(text).http.port, null)
|
||||
assert.ok(validateFormConfig('base', readFormConfig(text))['http.port'])
|
||||
for (const value of ['25', 0, -1, 65536, 1.5, null]) {
|
||||
assert.ok(validateFormConfig('imap', { imap: { tlsPort: value } })['imap.tlsPort'])
|
||||
assert.ok(validateFormConfig('mail', { imap: { tlsPort: value } })['imap.tlsPort'])
|
||||
}
|
||||
assert.ok(validateFormConfig('pop3', { imap: { tlsPort: 993 }, pop3: { port: 993, tlsPort: 995 } })['pop3.port'])
|
||||
assert.deepEqual(validateFormConfig('email', { policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 } }), {})
|
||||
assert.ok(validateFormConfig('mail', { imap: { tlsPort: 993 }, pop3: { port: 993, tlsPort: 995 } })['pop3.port'])
|
||||
assert.deepEqual(validateFormConfig('mail', {
|
||||
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
|
||||
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: { maxMessageBytes: 0 }
|
||||
}), {})
|
||||
})
|
||||
|
||||
test('域名校验兼容大小写和末尾点,拒绝重复项、空白和路径', () => {
|
||||
@ -268,25 +285,25 @@ test('SMTP 加密方式自动匹配端口,手动端口和读取过程不改变
|
||||
for (const [security, expectedPort] of [['starttls', 587], ['implicit_tls', 465], ['tls', 465], ['plain', 25]]) {
|
||||
const config = { generation: 9, relay: { relayHost: 'localhost', relayPort: 2525, future: true } }
|
||||
let text = JSON.stringify(config)
|
||||
getFieldGroups('smtp', config)
|
||||
getFieldGroups('mail', config)
|
||||
assert.equal(JSON.stringify(config), text)
|
||||
text = updateFormConfig(text, 'smtp', ['relay', 'security'], security)
|
||||
text = updateFormConfig(text, 'mail', ['relay', 'security'], security)
|
||||
assert.deepEqual(readFormConfig(text), { ...config, relay: { ...config.relay, security, relayPort: expectedPort } })
|
||||
const fields = getFieldGroups('smtp', readFormConfig(text)).flatMap(group => group.fields)
|
||||
const fields = getFieldGroups('mail', readFormConfig(text)).flatMap(group => group.fields)
|
||||
assert.equal(fields.find(field => field.path.at(-1) === 'relayPort').recommendedPort, expectedPort)
|
||||
text = updateFormConfig(text, 'smtp', ['relay', 'relayPort'], 2526)
|
||||
text = updateFormConfig(text, 'mail', ['relay', 'relayPort'], 2526)
|
||||
assert.equal(readFormConfig(text).relay.security, security)
|
||||
text = updateFormConfig(text, 'smtp', ['relay', 'security'], security)
|
||||
text = updateFormConfig(text, 'mail', ['relay', 'security'], security)
|
||||
assert.equal(readFormConfig(text).relay.relayPort, 2526)
|
||||
}
|
||||
const fields = getFieldGroups('smtp', {}).flatMap(group => group.fields)
|
||||
const fields = getFieldGroups('mail', {}).flatMap(group => group.fields)
|
||||
assert.ok(fields.findIndex(field => field.path.at(-1) === 'security') < fields.findIndex(field => field.path.at(-1) === 'relayPort'))
|
||||
const unused = JSON.stringify({ relay: { relayPort: 2525, security: 'starttls' } })
|
||||
assert.equal(readFormConfig(updateFormConfig(unused, 'smtp', ['relay', 'security'], '')).relay.relayPort, 2525)
|
||||
assert.equal(readFormConfig(updateFormConfig(unused, 'mail', ['relay', 'security'], '')).relay.relayPort, 2525)
|
||||
for (const security of ['constructor', '__proto__', 'future']) {
|
||||
const updated = readFormConfig(updateFormConfig(unused, 'smtp', ['relay', 'security'], security))
|
||||
const updated = readFormConfig(updateFormConfig(unused, 'mail', ['relay', 'security'], security))
|
||||
assert.equal(updated.relay.relayPort, 2525)
|
||||
const fields = getFieldGroups('smtp', updated).flatMap(group => group.fields)
|
||||
const fields = getFieldGroups('mail', updated).flatMap(group => group.fields)
|
||||
assert.equal(fields.find(field => field.path.at(-1) === 'relayPort').recommendedPort, undefined)
|
||||
}
|
||||
})
|
||||
@ -311,17 +328,22 @@ test('内置 HTTPS 自动补全端口,反代禁用内置监听,已有自定
|
||||
})
|
||||
|
||||
test('加密配置校验拦截监听冲突、远程明文和已启用但未选择加密的中继', () => {
|
||||
const listeners = {
|
||||
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
|
||||
imap: { tlsPort: 993 },
|
||||
pop3: { port: 110, tlsPort: 995 }
|
||||
}
|
||||
for (const port of [80, 0]) {
|
||||
assert.ok(validateFormConfig('base', { http: { port, httpsEnabled: true, httpsPort: 80 } })['http.httpsPort'])
|
||||
}
|
||||
for (const relayHost of ['localhost', 'LOCALHOST', '127.0.0.1', '127.1.2.3', '::1', '0:0:0:0:0:0:0:1']) {
|
||||
assert.deepEqual(validateFormConfig('smtp', { relay: { relayHost, security: 'plain', relayPort: 25 } }), {})
|
||||
assert.deepEqual(validateFormConfig('mail', { ...listeners, relay: { relayHost, security: 'plain', relayPort: 25, from: 'local@example.com', fromDomain: 'example.com' } }), {})
|
||||
}
|
||||
for (const relayHost of ['smtp.example.com', '127.0.0.999', '127.1', '127.0.0.1.evil.com', '192.168.1.1']) {
|
||||
assert.ok(validateFormConfig('smtp', { relay: { relayHost, security: 'plain', relayPort: 25 } })['relay.relayHost'])
|
||||
assert.ok(validateFormConfig('mail', { ...listeners, relay: { relayHost, security: 'plain', relayPort: 25, from: 'sender@example.com', fromDomain: 'example.com' } })['relay.relayHost'])
|
||||
}
|
||||
const errors = validateFormConfig('smtp', { relay: { relayHost: 'smtp.example.com', security: 'starttls', relayPort: 0 } })
|
||||
const errors = validateFormConfig('mail', { ...listeners, relay: { relayHost: 'smtp.example.com', security: 'starttls', relayPort: 0, from: 'sender@example.com', fromDomain: 'example.com' } })
|
||||
assert.ok(errors['relay.relayPort'])
|
||||
assert.deepEqual(validateFormConfig('smtp', { relay: {} }), {})
|
||||
assert.deepEqual(validateFormConfig('smtp', { relay: { security: 'starttls', relayPort: 2525, relayHost: 'smtp.example.com' } }), {})
|
||||
assert.deepEqual(validateFormConfig('mail', { ...listeners, relay: {} }), {})
|
||||
assert.deepEqual(validateFormConfig('mail', { ...listeners, relay: { security: 'starttls', relayPort: 2525, relayHost: 'smtp.example.com', from: 'sender@example.com', fromDomain: 'example.com' } }), {})
|
||||
})
|
||||
|
||||
@ -5,7 +5,7 @@ import { compileFunction } from 'node:vm'
|
||||
import { computed, reactive, ref } from './vue.mjs'
|
||||
|
||||
const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigNavigation.js', import.meta.url), 'utf8')
|
||||
const components = ['BaseConfig', 'SmtpConfig', 'ImapConfig', 'Pop3Config', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']
|
||||
const components = ['BaseConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']
|
||||
const createNavigation = compileFunction(
|
||||
source.replace(/^import .*$/gm, '').replace('export function useNavigation', 'function useNavigation') + '\nreturn useNavigation()',
|
||||
['computed', 'nextTick', 'ref', 'useGlobalStatusStore', ...components]
|
||||
@ -20,8 +20,8 @@ function fixture(capabilities = ['config.read']) {
|
||||
|
||||
test('配置菜单包含三个独立插件三级页面,无权限的维护入口不可打开', async () => {
|
||||
const { page, allowed } = fixture()
|
||||
assert.equal(new Set(page.sectionOptions.value.map(item => item.component)).size, 7)
|
||||
assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins'])
|
||||
assert.equal(new Set(page.sectionOptions.value.map(item => item.component)).size, 4)
|
||||
assert.deepEqual(page.sectionOptions.value.map(item => item.value), ['base', 'mail', 'ssl', 'plugins'])
|
||||
const children = page.sectionOptions.value.find(item => item.value === 'plugins').children
|
||||
assert.deepEqual(children.map(item => item.value), ['spam_block', 'telegram_push', 'wechat_push'])
|
||||
await page.selectSection('plugins')
|
||||
@ -61,17 +61,17 @@ test('切页等待离开确认,取消和快速重复点击不会更改页面',
|
||||
let resolve
|
||||
let prompts = 0
|
||||
page.currentPage.value = { beforeLeave: () => { prompts++; return new Promise(done => { resolve = done }) } }
|
||||
const first = page.selectSection('smtp')
|
||||
const first = page.selectSection('mail')
|
||||
await page.selectSection('ssl')
|
||||
assert.equal(prompts, 1)
|
||||
assert.equal(page.selectedSection.value, 'base')
|
||||
resolve(false)
|
||||
await first
|
||||
assert.equal(page.selectedSection.value, 'base')
|
||||
const second = page.selectSection('email')
|
||||
const second = page.selectSection('mail')
|
||||
resolve(true)
|
||||
await second
|
||||
assert.equal(page.selectedSection.value, 'email')
|
||||
assert.equal(page.selectedSection.value, 'mail')
|
||||
assert.equal(page.activePage.value.component.name, 'EmailConfig')
|
||||
})
|
||||
|
||||
|
||||
@ -45,8 +45,7 @@ test('证书页从状态接口读取生效反代模式,不额外加载基础
|
||||
assert.equal(page.status.tlsMode, 'proxy')
|
||||
assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/ssl'])
|
||||
const template = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue', import.meta.url), 'utf8')
|
||||
assert.match(template, /v-if="selectedSection === 'ssl' && status.tlsMode === 'proxy'"/)
|
||||
assert.match(template, /Smail 不自动申请或续期 SSL 证书/)
|
||||
assert.doesNotMatch(template, /selectedSection === 'ssl' && status\.tlsMode === 'proxy'|Smail 不自动申请或续期 SSL 证书/)
|
||||
})
|
||||
|
||||
for (const failedRequest of ['/status', '/pending']) {
|
||||
@ -54,14 +53,22 @@ for (const failedRequest of ['/status', '/pending']) {
|
||||
let pending = null
|
||||
let fail = false
|
||||
let saves = 0
|
||||
const { page } = fixture(() => {}, 'smtp', { http: {
|
||||
const active = {
|
||||
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
|
||||
relay: {
|
||||
relayHost: 'old.example.com', relayPort: 587, security: 'starttls',
|
||||
from: 'sender@example.com', fromDomain: 'example.com'
|
||||
},
|
||||
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
|
||||
}
|
||||
const { page } = fixture(() => {}, 'mail', { http: {
|
||||
get: async path => {
|
||||
if (fail && path.endsWith(failedRequest)) throw new Error('模拟读取失败')
|
||||
return { errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 1 } : { relay: { relayHost: 'old.example.com' } } }
|
||||
return { errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 1 } : active }
|
||||
},
|
||||
put: async (path, body) => {
|
||||
saves++
|
||||
pending = { generation: 2, candidateId: 'pending-recovery', section: 'smtp', sameSession: true, payload: body.payload }
|
||||
pending = { generation: 2, candidateId: 'pending-recovery', section: 'mail', sameSession: true, payload: body.payload }
|
||||
fail = true
|
||||
return { errorNo: 0, data: pending }
|
||||
},
|
||||
@ -173,7 +180,7 @@ test('状态请求失败后可重试恢复,不保留不可用提示', async ()
|
||||
assert.equal(page.availabilityError.value, '')
|
||||
})
|
||||
|
||||
test('独立页互不覆盖,旧页卸载时清除密码并丢弃较慢响应', async () => {
|
||||
test('独立页互不覆盖,页面卸载时清除密码并丢弃较慢响应', async () => {
|
||||
const base = deferred()
|
||||
const email = deferred()
|
||||
const { page, dispose } = fixture(() => base.promise)
|
||||
@ -188,11 +195,11 @@ test('独立页互不覆盖,旧页卸载时清除密码并丢弃较慢响应',
|
||||
page.secretValue.value = 'test-only-value'
|
||||
dispose()
|
||||
assert.equal(page.secretValue.value, '')
|
||||
const emailPage = fixture(() => email.promise, 'email').page
|
||||
const emailPage = fixture(() => email.promise, 'mail').page
|
||||
const emailRequest = emailPage.loadSection()
|
||||
email.resolve({ errorNo: 0, data: { policy: { smtpPort: 25 } } })
|
||||
await emailRequest
|
||||
assert.equal(emailPage.currentSection.value.label, '邮箱策略')
|
||||
assert.equal(emailPage.currentSection.value.label, '邮件服务与策略')
|
||||
assert.equal(emailPage.sectionLoading.value, false)
|
||||
base.resolve({ errorNo: 0, data: { database: { type: 'sqlite' } } })
|
||||
await baseRequest
|
||||
@ -219,34 +226,39 @@ test('同一页重复加载时,过期失败不覆盖最新请求', async () =>
|
||||
assert.deepEqual(JSON.parse(page.editorText.value), { enabled: true })
|
||||
})
|
||||
|
||||
test('七个配置页按需加载,协议页复用正确分区且提供中文说明', async () => {
|
||||
test('配置页按需加载,邮件页和安全策略页共用 mail 分区', async () => {
|
||||
const requests = []
|
||||
for (const key of ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins']) {
|
||||
for (const key of ['base', 'mail', 'security_policy', 'ssl', 'plugins']) {
|
||||
const { page } = fixture(async path => { requests.push(path); return { errorNo: 0, data: {} } }, key)
|
||||
await page.loadAll()
|
||||
assert.equal(page.selectedSection.value, key)
|
||||
assert.match(page.currentSection.value.description, /[\u4e00-\u9fff]/)
|
||||
assert.match(page.currentSection.value.label, /[\u4e00-\u9fff]/)
|
||||
assert.equal(page.secretValue.value, '')
|
||||
assert.equal(page.confirmed.value, false)
|
||||
assert.equal(page.savedGeneration.value, 0)
|
||||
}
|
||||
assert.deepEqual(requests, ['base', 'smtp', 'imap', 'pop3', 'email', 'ssl', 'plugins']
|
||||
.flatMap(key => ['/api/v1/config/status', `/api/v1/config/${['imap', 'pop3'].includes(key) ? 'email' : key}`, ...(key === 'email' ? ['/api/v1/config/smtp'] : [])]))
|
||||
assert.deepEqual(requests, ['base', 'mail', 'security_policy', 'ssl', 'plugins']
|
||||
.flatMap(key => ['/api/v1/config/status', `/api/v1/config/${key === 'security_policy' ? 'mail' : key}`]))
|
||||
})
|
||||
|
||||
for (const key of ['imap', 'pop3']) {
|
||||
test(`${key} 页面验证、保存、候选恢复及应用均使用邮箱分区且保留其他字段`, async () => {
|
||||
for (const key of ['mail', 'security_policy']) {
|
||||
test(`${key} 页面验证、保存、候选恢复及应用均使用 mail 分区且保留未展示字段`, async () => {
|
||||
const requests = []
|
||||
const payloads = []
|
||||
let pending = null
|
||||
let active = { meta: { schema: 1, generation: 1 }, policy: { maxMessageBytes: 1234 }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 } }
|
||||
let active = {
|
||||
meta: { schema: 1, generation: 1 },
|
||||
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
|
||||
relay: { verificationEnabled: true, loginPolicy: 'new_device' },
|
||||
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 },
|
||||
policy: { maxMessageBytes: 1234, spamFilterLevel: 1, externalRecoveryEnabled: true }
|
||||
}
|
||||
const { page } = fixture(() => {}, key, { http: {
|
||||
get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : structuredClone(active) }),
|
||||
put: async (path, body) => {
|
||||
requests.push(path)
|
||||
assert.equal(body.expectedGeneration, 1)
|
||||
assert.deepEqual(body.payload.policy, active.policy)
|
||||
assert.deepEqual(body.payload[key === 'imap' ? 'pop3' : 'imap'], active[key === 'imap' ? 'pop3' : 'imap'])
|
||||
pending = { generation: 2, candidateId: 'mail-candidate', section: 'email', sameSession: true, payload: body.payload }
|
||||
payloads.push(structuredClone(body))
|
||||
pending = { generation: 2, candidateId: 'mail-candidate', section: 'mail', sameSession: true, payload: body.payload }
|
||||
return { errorNo: 0, data: pending }
|
||||
},
|
||||
post: async (path, body) => {
|
||||
@ -260,48 +272,50 @@ for (const key of ['imap', 'pop3']) {
|
||||
}
|
||||
} })
|
||||
await page.loadAll()
|
||||
page.updateField([key, 'tlsPort'], key === 'imap' ? 1993 : 1995)
|
||||
const path = key === 'mail' ? ['imap', 'tlsPort'] : ['relay', 'loginPolicy']
|
||||
page.updateField(path, key === 'mail' ? 1993 : 'always')
|
||||
await page.validateConfig()
|
||||
await page.saveConfig()
|
||||
await page.refreshPage()
|
||||
assert.equal(page.savedGeneration.value, 2)
|
||||
page.updateField([key, 'tlsPort'], 8888)
|
||||
assert.notEqual(page.formConfig.value[key].tlsPort, 8888)
|
||||
assert.equal(payloads[0].expectedGeneration, 1)
|
||||
assert.deepEqual(payloads[0].payload.listener, active.listener)
|
||||
assert.deepEqual(payloads[0].payload.pop3, active.pop3)
|
||||
if (key === 'security_policy') assert.equal(payloads[0].secretChanges['relay.password'], undefined)
|
||||
if (key === 'security_policy') {
|
||||
await page.refreshPage()
|
||||
assert.equal(page.savedGeneration.value, 2)
|
||||
}
|
||||
page.updateField(path, key === 'mail' ? 8888 : 'off')
|
||||
assert.notEqual(key === 'mail' ? page.formConfig.value.imap.tlsPort : page.formConfig.value.relay.loginPolicy, key === 'mail' ? 8888 : 'off')
|
||||
await page.applyConfig()
|
||||
assert.equal(page.status.activeGeneration, 2)
|
||||
assert.equal(page.savedGeneration.value, 0)
|
||||
assert.deepEqual(requests, ['/api/v1/config/email/validate', '/api/v1/config/email', '/api/v1/config/email/apply'])
|
||||
assert.deepEqual(requests, ['/api/v1/config/mail/validate', '/api/v1/config/mail', '/api/v1/config/mail/apply'])
|
||||
})
|
||||
}
|
||||
|
||||
test('邮箱策略从三种协议配置汇总已应用端口,不能修改或被候选值误导', async () => {
|
||||
const config = { meta: { generation: 3 }, policy: { smtpPort: 25, imapPort: 143, pop3Port: 110 }, imap: { tlsPort: 1993 }, pop3: { port: 1110, tlsPort: 1995 } }
|
||||
const { page } = fixture(() => {}, 'email', { http: { get: async path => ({ errorNo: 0, data:
|
||||
path.endsWith('/status') ? { activeGeneration: 3 } : path.endsWith('/smtp') ? { meta: { generation: 3 }, listener: { port: 2525, starttlsPort: 2587, tlsPort: 2465 }, relay: { relayPort: 9999 } } :
|
||||
path.endsWith('/pending') ? { section: 'email', generation: 4, sameSession: true, payload: { ...config, imap: { tlsPort: 2993 } } } : config
|
||||
}) } })
|
||||
await page.loadAll()
|
||||
assert.deepEqual(page.mailPortSummary.value.flatMap(service => service.ports.map(port => port.value)), [2525, 2587, 2465, 1993, 1110, 1995])
|
||||
assert.equal(page.formConfig.value.imap.tlsPort, 2993)
|
||||
const before = page.editorText.value
|
||||
for (const path of [['policy', 'smtpPort'], ['policy', 'imapPort'], ['imap', 'tlsPort']]) page.updateField(path, 8888)
|
||||
assert.equal(page.editorText.value, before)
|
||||
})
|
||||
|
||||
test('邮箱策略拒绝不同代次的端口汇总,后续加载失败清除旧汇总', async () => {
|
||||
let generation = 2
|
||||
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/smtp')
|
||||
? { meta: { generation }, listener: { port: 2525 } }
|
||||
: { meta: { generation: 1 }, imap: { tlsPort: 1993 }, pop3: { port: 1110, tlsPort: 1995 } } }), 'email')
|
||||
test('安全策略页只编辑登录验证和账号恢复字段,完整保留邮件监听、中继与邮件策略', async () => {
|
||||
const config = {
|
||||
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
|
||||
relay: { relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls', verificationEnabled: true, loginPolicy: 'new_device' },
|
||||
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 },
|
||||
policy: { maxMessageBytes: 10485760, spamFilterLevel: 2, externalRecoveryEnabled: true }
|
||||
}
|
||||
let request
|
||||
const { page } = fixture(async () => ({ errorNo: 0, data: config }), 'security_policy', {
|
||||
http: { put: async (path, body) => { request = { path, body }; return { errorNo: 0, data: { generation: 2 } } } }
|
||||
})
|
||||
await page.loadSection()
|
||||
assert.match(page.sectionError.value, /代次/)
|
||||
assert.equal(page.mailPortSummary.value, null)
|
||||
generation = 1
|
||||
await page.loadSection()
|
||||
assert.ok(page.mailPortSummary.value)
|
||||
generation = 3
|
||||
await page.loadSection()
|
||||
assert.equal(page.mailPortSummary.value, null)
|
||||
page.updateField(['relay', 'loginPolicy'], 'always')
|
||||
page.updateField(['listener', 'port'], 2525)
|
||||
await page.saveConfig()
|
||||
assert.equal(request.path, '/api/v1/config/mail')
|
||||
assert.equal(request.body.payload.listener.port, 25)
|
||||
assert.equal(request.body.payload.relay.loginPolicy, 'always')
|
||||
assert.deepEqual(request.body.payload.imap, config.imap)
|
||||
assert.deepEqual(request.body.payload.pop3, config.pop3)
|
||||
assert.equal(request.body.payload.policy.maxMessageBytes, config.policy.maxMessageBytes)
|
||||
assert.deepEqual(request.body.secretChanges, {})
|
||||
})
|
||||
|
||||
test('迁移预览票据返回模板,迁移执行取消时不发送写入请求', async () => {
|
||||
@ -382,7 +396,7 @@ test('独立页模板可编译,编辑器不再连接维护面板且权限绑
|
||||
assert.ok(maintenance.includes(`pageKey === '${key}' && ${capability}`))
|
||||
}
|
||||
assert.match(maintenance, /v-if="canExportAudit"/)
|
||||
for (const name of ['BaseConfig', 'SmtpConfig', 'ImapConfig', 'Pop3Config', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance', 'MailPortSummary']) {
|
||||
for (const name of ['BaseConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']) {
|
||||
const page = readFileSync(new URL(`${name}/${name}.vue`, root), 'utf8')
|
||||
const { descriptor, errors } = parse(page)
|
||||
assert.deepEqual(errors, [])
|
||||
@ -416,9 +430,9 @@ test('配置编辑区移除填写标题、说明与重复标签,保留主标
|
||||
assert.doesNotMatch(template, /'填写配置'|'查看配置'|按分组填写内容|当前页面的脱敏配置内容|panel-heading|selected-section-label/)
|
||||
assert.doesNotMatch(css, /selected-section-label/)
|
||||
assert.match(template, /<h3 id="config-page-title">{{ currentSection.label }}/)
|
||||
assert.match(template, /v-if="selectedSection === 'ssl' && status.tlsMode === 'proxy'"[^>]*role="note"/)
|
||||
assert.doesNotMatch(template, /selectedSection === 'ssl' && status\.tlsMode === 'proxy'|role="note"/)
|
||||
assert.match(template, /v-if="sectionError"[\s\S]*?:title="sectionError"/)
|
||||
assert.match(template, /id="config-sensitive-hint"/)
|
||||
assert.doesNotMatch(template, /config-sensitive-hint|sensitiveHint/)
|
||||
assert.match(template, /v-if="secretField" class="secret-editor"/)
|
||||
assert.match(template, /v-if="validationResult"[^>]*role="status"/)
|
||||
})
|
||||
@ -426,7 +440,7 @@ test('配置编辑区移除填写标题、说明与重复标签,保留主标
|
||||
test('离开、刷新和正在执行的变更受保护,取消时保留编辑内容', async () => {
|
||||
let cancel = true
|
||||
let prompts = 0
|
||||
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', {
|
||||
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'mail', {
|
||||
confirm: async () => { prompts++; if (cancel) throw 'cancel' }
|
||||
})
|
||||
await page.loadAll()
|
||||
@ -461,7 +475,7 @@ test('标题合并配置状态,保留刷新保护和降级提示,不再渲
|
||||
const header = template.match(/<header[\s\S]*?<\/header>/)?.[0] || ''
|
||||
assert.match(header, /aria-labelledby="config-page-title"/)
|
||||
assert.match(header, /<h3 id="config-page-title">{{ currentSection.label }}/)
|
||||
assert.match(header, /<span class="sr-only">运行状态:<\/span>{{ stateLabel }}/)
|
||||
assert.doesNotMatch(header, /stateLabel|运行状态|state-tag/)
|
||||
assert.match(header, /v-for="issue in status.degradedReasons \|\| \[\]"/)
|
||||
assert.match(header, /v-if="!loading && !availabilityError"/)
|
||||
assert.match(header, /:disabled="!!working \|\| sectionLoading \|\| loading"/)
|
||||
@ -497,16 +511,24 @@ test('只读账号不得发送配置写入请求或读取无权限维护数据',
|
||||
assert.deepEqual(requests, ['/api/v1/config/status'])
|
||||
})
|
||||
|
||||
test('独立页保持验证、保存、应用端点和代次协议,敏感值不串页', async () => {
|
||||
test('邮件页保持验证、保存、应用端点和代次协议,中继密码只作为 secretChanges 发送', async () => {
|
||||
const requests = []
|
||||
let pending = null
|
||||
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: 8 } : { relayPasswordStatus: 'configured' } }), 'smtp', {
|
||||
const active = {
|
||||
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
|
||||
relay: {
|
||||
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
|
||||
from: 'sender@example.com', fromDomain: 'example.com'
|
||||
},
|
||||
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
|
||||
}
|
||||
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: 8 } : active }), 'mail', {
|
||||
http: {
|
||||
get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 8 } : { relayPasswordStatus: 'configured' } }),
|
||||
get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 8 } : active }),
|
||||
post: async (path, body) => { requests.push({ path, body }); if (path.endsWith('/apply')) pending = null; return { errorNo: 0, data: { valid: true } } },
|
||||
put: async (path, body) => {
|
||||
requests.push({ path, body })
|
||||
pending = { generation: 9, candidateId: 'candidate-9', section: 'smtp', sameSession: true, payload: {} }
|
||||
pending = { generation: 9, candidateId: 'candidate-9', section: 'mail', sameSession: true, payload: body.payload }
|
||||
return { errorNo: 0, data: pending }
|
||||
}
|
||||
}
|
||||
@ -518,9 +540,10 @@ test('独立页保持验证、保存、应用端点和代次协议,敏感值
|
||||
await page.saveConfig()
|
||||
assert.equal(page.savedGeneration.value, 9)
|
||||
await page.applyConfig()
|
||||
assert.deepEqual(requests.map(item => item.path), ['/api/v1/config/smtp/validate', '/api/v1/config/smtp', '/api/v1/config/smtp/apply'])
|
||||
assert.deepEqual(requests.map(item => item.path), ['/api/v1/config/mail/validate', '/api/v1/config/mail', '/api/v1/config/mail/apply'])
|
||||
assert.equal(requests[0].body.expectedGeneration, 8)
|
||||
assert.deepEqual(requests[0].body.payload, {})
|
||||
assert.equal(requests[0].body.payload.relay.password, undefined)
|
||||
assert.deepEqual(requests[0].body.payload.listener, active.listener)
|
||||
assert.deepEqual(requests[0].body.secretChanges, { 'relay.password': { operation: 'replace', value: { plaintext: 'test-only-value' } } })
|
||||
assert.deepEqual(requests[0].body.confirmation, { confirmed: true, method: 'recent_auth' })
|
||||
assert.deepEqual(requests[2].body, { generation: 9, candidateId: 'candidate-9' })
|
||||
@ -530,7 +553,11 @@ test('独立页保持验证、保存、应用端点和代次协议,敏感值
|
||||
})
|
||||
|
||||
test('可视化编辑按权限和工作状态保护,编辑会清除过期确认及候选代次', async () => {
|
||||
const { page, dispose } = fixture(async () => ({ errorNo: 0, data: { relay: { relayHost: 'smtp.example.com' } } }), 'smtp')
|
||||
const config = {
|
||||
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
|
||||
relay: { relayHost: 'smtp.example.com' }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
|
||||
}
|
||||
const { page, dispose } = fixture(async () => ({ errorNo: 0, data: config }), 'mail')
|
||||
await page.loadAll()
|
||||
const original = page.editorText.value
|
||||
for (const state of ['loading', 'sectionLoading', 'working', 'sectionError']) {
|
||||
@ -541,7 +568,7 @@ test('可视化编辑按权限和工作状态保护,编辑会清除过期确
|
||||
assert.equal(page.secretValue.value, '')
|
||||
page[state].value = false
|
||||
}
|
||||
const readonly = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', { hasCapability: () => false }).page
|
||||
const readonly = fixture(async () => ({ errorNo: 0, data: {} }), 'mail', { hasCapability: () => false }).page
|
||||
readonly.updateField(['relay', 'relayHost'], 'blocked.example.com')
|
||||
assert.equal(readonly.editorText.value, '')
|
||||
page.savedGeneration.value = 8
|
||||
@ -562,7 +589,15 @@ test('可视化编辑按权限和工作状态保护,编辑会清除过期确
|
||||
|
||||
test('前端无效字段阻止网络请求,修正字段后同步清除错误', async () => {
|
||||
const requests = []
|
||||
const { page } = fixture(async () => ({ errorNo: 0, data: { imap: { tlsPort: 993 } } }), 'imap', {
|
||||
const config = {
|
||||
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
|
||||
relay: {
|
||||
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
|
||||
from: 'sender@example.com', fromDomain: 'example.com'
|
||||
},
|
||||
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
|
||||
}
|
||||
const { page } = fixture(async () => ({ errorNo: 0, data: config }), 'mail', {
|
||||
http: { post: async path => { requests.push(path); return { errorNo: 0, data: { valid: true } } } }
|
||||
})
|
||||
await page.loadAll()
|
||||
@ -574,7 +609,7 @@ test('前端无效字段阻止网络请求,修正字段后同步清除错误',
|
||||
page.updateField(['imap', 'tlsPort'], 1993)
|
||||
assert.deepEqual(page.formErrors.value, {})
|
||||
await page.validateConfig()
|
||||
assert.deepEqual(requests, ['/api/v1/config/email/validate'])
|
||||
assert.deepEqual(requests, ['/api/v1/config/mail/validate'])
|
||||
})
|
||||
|
||||
test('插件读取状态字段不传回严格解析接口,未展示字段和权限仍保留', async () => {
|
||||
@ -614,7 +649,15 @@ test('SQLite 不显示或发送密码,网络数据库和中继密码留空保
|
||||
assert.deepEqual(request.secretChanges, type === 'sqlite' ? {} : { 'database.password': { operation: 'replace', value: { plaintext: ' test-only-value ' } } })
|
||||
}
|
||||
let request
|
||||
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', {
|
||||
const mail = {
|
||||
listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
|
||||
relay: {
|
||||
relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls',
|
||||
from: 'sender@example.com', fromDomain: 'example.com'
|
||||
},
|
||||
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: {}
|
||||
}
|
||||
const { page } = fixture(async () => ({ errorNo: 0, data: mail }), 'mail', {
|
||||
http: { post: async (path, body) => { request = body; return { errorNo: 0, data: { valid: true } } } }
|
||||
})
|
||||
await page.loadSection()
|
||||
@ -644,7 +687,7 @@ test('基础配置保存完整保留隐藏的额外访问主机、数据库和
|
||||
|
||||
test('保存后更改密码或填写内容不能应用旧候选', async () => {
|
||||
const requests = []
|
||||
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'smtp', {
|
||||
const { page } = fixture(async () => ({ errorNo: 0, data: {} }), 'mail', {
|
||||
http: {
|
||||
put: async () => ({ errorNo: 0, data: { generation: 8 } }),
|
||||
post: async path => { requests.push(path); return { errorNo: 0 } }
|
||||
@ -662,19 +705,24 @@ test('保存后更改密码或填写内容不能应用旧候选', async () => {
|
||||
assert.deepEqual(requests, [])
|
||||
})
|
||||
|
||||
test('共用邮箱分区的候选明确提示其他页面的改动,忽略代次与字段顺序', async () => {
|
||||
const active = { meta: { generation: 1 }, imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 }, policy: { maxMessageBytes: 1234 } }
|
||||
for (const key of ['imap', 'pop3', 'email']) {
|
||||
const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/smtp') ? { meta: { generation: 1 }, listener: {} } : active }), key)
|
||||
test('邮件页与安全策略页共享候选并明确提示改动范围,忽略代次与字段顺序', async () => {
|
||||
const active = {
|
||||
meta: { generation: 1 }, listener: { port: 25, starttlsPort: 587, tlsPort: 465 },
|
||||
relay: { verificationEnabled: true, loginPolicy: 'new_device' },
|
||||
imap: { tlsPort: 993 }, pop3: { port: 110, tlsPort: 995 },
|
||||
policy: { maxMessageBytes: 1234, externalRecoveryEnabled: true }
|
||||
}
|
||||
for (const key of ['mail', 'security_policy']) {
|
||||
const { page } = fixture(async () => ({ errorNo: 0, data: active }), key)
|
||||
await page.loadSection()
|
||||
page.pendingCandidate.value = { section: 'email', payload: { ...active, meta: { generation: 2 }, pop3: { tlsPort: 995, port: 110 } } }
|
||||
assert.match(page.pendingScopeHint.value, /协议端口和邮箱策略未变化/)
|
||||
page.pendingCandidate.value = { section: 'mail', payload: { ...active, meta: { generation: 2 }, pop3: { tlsPort: 995, port: 110 } } }
|
||||
assert.match(page.pendingScopeHint.value, /邮件服务与安全策略未变化/)
|
||||
page.pendingCandidate.value.payload = { ...active, pop3: { port: 1110, tlsPort: 1995 }, policy: { maxMessageBytes: 5678 } }
|
||||
assert.match(page.pendingScopeHint.value, /本次候选涉及:POP3 配置、邮箱策略。/)
|
||||
assert.match(page.pendingScopeHint.value, /请逐页核对;应用时会一起生效/)
|
||||
assert.match(page.pendingScopeHint.value, /本次候选涉及:POP3 监听、邮件与账号策略。/)
|
||||
assert.match(page.pendingScopeHint.value, /邮件服务页和安全策略页共用一个候选配置/)
|
||||
page.pendingCandidate.value = { section: 'smtp' }
|
||||
assert.equal(page.pendingScopeHint.value, '')
|
||||
page.pendingCandidate.value = { section: 'email' }
|
||||
page.pendingCandidate.value = { section: 'mail' }
|
||||
assert.match(page.pendingScopeHint.value, /候选内容尚未完整读取/)
|
||||
}
|
||||
const template = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigEditor/ConfigEditor.vue', import.meta.url), 'utf8')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user