371 lines
15 KiB
Go
371 lines
15 KiB
Go
package config
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
"time"
|
||
|
||
_ "modernc.org/sqlite"
|
||
)
|
||
|
||
func TestAuditAutomaticSSLPolicy(t *testing.T) {
|
||
var empty *Config
|
||
if empty.UsesProxyHTTPS() || empty.AutomaticSSLEnabled() {
|
||
t.Fatal("空配置不能启用自动证书")
|
||
}
|
||
for _, mode := range []string{"", HTTPSTLSModeDirect, HTTPSTLSModeProxy, " PROXY "} {
|
||
for _, sslType := range []string{"", SSLTypeUser, SSLTypeAutoHTTP, SSLTypeAutoDNS} {
|
||
cfg := &Config{TLSMode: mode, SSLType: sslType}
|
||
want := (mode == "" || mode == HTTPSTLSModeDirect) && (sslType == SSLTypeAutoHTTP || sslType == SSLTypeAutoDNS)
|
||
if cfg.AutomaticSSLEnabled() != want {
|
||
t.Fatalf("自动证书策略错误: mode=%q type=%q", mode, sslType)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAuditProxySwitchRejectsStaleSSLCommit(t *testing.T) {
|
||
previousRoot, previousSnapshot := ROOT_PATH, CurrentConfig()
|
||
ROOT_PATH = t.TempDir()
|
||
t.Cleanup(func() {
|
||
ROOT_PATH = previousRoot
|
||
PublishConfigSnapshot(previousSnapshot)
|
||
})
|
||
expected := &Config{TLSMode: HTTPSTLSModeDirect, SSLType: SSLTypeAutoDNS, HttpsEnabled: 2}
|
||
if err := WriteConfig(expected); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
live := *expected
|
||
// 只修改 TLSMode,确保拒绝不是由 HTTPS 开关或域名变化引起。
|
||
live.TLSMode = HTTPSTLSModeProxy
|
||
if err := WriteConfig(&live); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := WriteSSLConfig(expected, expected); !errors.Is(err, ErrGenerationConflict) {
|
||
t.Fatalf("切换反代后未拒绝旧签发结果: %v", err)
|
||
}
|
||
stored, err := ReadConfig()
|
||
if err != nil || !stored.UsesProxyHTTPS() {
|
||
t.Fatal("过期签发改变了反代策略")
|
||
}
|
||
}
|
||
|
||
func TestAuditTLSStatusTracksAppliedPolicy(t *testing.T) {
|
||
root := t.TempDir()
|
||
runtime := validRuntimeConfig()
|
||
runtime.Root.HTTP.TLSMode = HTTPSTLSModeDirect
|
||
writeRuntimeFixture(t, root, runtime)
|
||
manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
actor := authorizedConfigActor()
|
||
for _, mode := range []string{HTTPSTLSModeProxy, HTTPSTLSModeDirect} {
|
||
current := manager.Snapshot()
|
||
next := current.Root
|
||
next.HTTP.TLSMode = mode
|
||
next.HTTP.HTTPSEnabled = mode == HTTPSTLSModeDirect
|
||
next.HTTP.HTTPSPort = 0
|
||
if next.HTTP.HTTPSEnabled {
|
||
next.HTTP.HTTPSPort = 443
|
||
}
|
||
next.HTTP.TrustedProxyCIDRs = []string{"127.0.0.1/32"}
|
||
payload, err := json.Marshal(next)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
updated, err := manager.Update(context.Background(), UpdateRequest{
|
||
Section: SectionRoot, ExpectedGeneration: current.Generation, Payload: payload, Actor: actor,
|
||
Confirmation: StepUpConfirmation{Confirmed: true, Method: "recent_auth"},
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if manager.Status().TLSMode != current.Root.HTTP.TLSMode {
|
||
t.Fatal("未应用的候选不能改变生效模式提示")
|
||
}
|
||
if _, err := manager.ApplyAuthorized(context.Background(), updated.Generation, actor); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if manager.Status().TLSMode != mode {
|
||
t.Fatal("状态没有跟随已应用的 HTTPS 模式")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAuditPendingIdentityAndRecovery(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)
|
||
}
|
||
actor := authorizedConfigActor()
|
||
payload, _ := json.Marshal(manager.Snapshot().Email)
|
||
req := UpdateRequest{Section: SectionEmail, ExpectedGeneration: 1, Payload: payload, Actor: actor}
|
||
first, err := manager.Update(context.Background(), req)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
confirmation := StepUpConfirmation{Confirmed: true, Method: "recent_auth"}
|
||
if err := manager.ManagePending(context.Background(), first.Generation, first.CandidateID, actor, confirmation, true); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
second, err := manager.Update(context.Background(), req)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if first.CandidateID == second.CandidateID {
|
||
t.Fatal("不同候选不能复用标识")
|
||
}
|
||
if _, err := manager.ApplyPendingAuthorized(context.Background(), first.Generation, first.CandidateID, SectionEmail, actor); !errors.Is(err, ErrGenerationConflict) {
|
||
t.Fatalf("旧候选应用未拒绝: %v", err)
|
||
}
|
||
if err := manager.ManagePending(context.Background(), first.Generation, first.CandidateID, actor, confirmation, true); !errors.Is(err, ErrGenerationConflict) {
|
||
t.Fatalf("旧候选取消未拒绝: %v", err)
|
||
}
|
||
actor.SessionID = "renewed-session"
|
||
actor.RecentAuth = false
|
||
if err := manager.ManagePending(context.Background(), second.Generation, second.CandidateID, actor, confirmation, false); err == nil {
|
||
t.Fatal("跨会话恢复缺少近期认证")
|
||
}
|
||
actor.RecentAuth = true
|
||
if err := manager.ManagePending(context.Background(), second.Generation, second.CandidateID, actor, confirmation, false); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !manager.Pending(actor).SameSession {
|
||
t.Fatal("恢复没有绑定新会话")
|
||
}
|
||
manager.snapshots = &flakyRuntimeSnapshotStore{current: manager.Snapshot(), failures: 1}
|
||
if _, err := manager.ApplyPendingAuthorized(context.Background(), second.Generation, second.CandidateID, SectionEmail, actor); !errors.Is(err, ErrRuntimeSnapshotUnavailable) {
|
||
t.Fatal(err)
|
||
}
|
||
if err := manager.ManagePending(context.Background(), second.Generation, second.CandidateID, actor, confirmation, true); !errors.Is(err, ErrRuntimeSnapshotUnavailable) {
|
||
t.Fatalf("已落盘候选不应取消: %v", err)
|
||
}
|
||
manager.transactions.WithHooks(TransactionHooks{BeforePhase: func(TransactionPhase) error { return errors.New("不能再次提交") }})
|
||
if _, err := manager.ApplyPendingAuthorized(context.Background(), second.Generation, second.CandidateID, SectionEmail, actor); err != nil {
|
||
t.Fatalf("快照重试重新写盘: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestAuditStagedSSLDoesNotReplaceCurrent(t *testing.T) {
|
||
previous := ROOT_PATH
|
||
ROOT_PATH = t.TempDir()
|
||
t.Cleanup(func() { ROOT_PATH = previous })
|
||
key, cert := testManagedCertificate(t, "mail.example.com", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048)
|
||
active, err := ImportManagedSSL(ManagedSSLModeDefault, key, cert, nil, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
nextKey, nextCert := testManagedCertificate(t, "mail.example.com", time.Now().Add(-time.Hour), time.Now().Add(time.Hour), 2048)
|
||
staged, err := StageManagedSSL(ManagedSSLModeDefault, nextKey, nextCert, nil, nil)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
actual, err := os.ReadFile(active.CertificatePath)
|
||
if err != nil || string(actual) != string(cert) {
|
||
t.Fatal("暂存替换了现用证书")
|
||
}
|
||
if _, err := ResolveManagedSSLReferences(SSLConfig{Mode: staged.Mode, CertificateRef: staged.CertificateRef, PrivateKeyRef: active.PrivateKeyRef}); !errors.Is(err, ErrInvalidSSLReference) {
|
||
t.Fatalf("跨版本配对未拒绝: %v", err)
|
||
}
|
||
if _, err := ResolveManagedSSLReferences(SSLConfig{Mode: staged.Mode, CertificateRef: staged.CertificateRef, PrivateKeyRef: staged.PrivateKeyRef}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func TestAuditLegacySSLCommitPreservesLatestUnrelatedSettings(t *testing.T) {
|
||
previousRoot, previousSnapshot := ROOT_PATH, CurrentConfig()
|
||
ROOT_PATH = t.TempDir()
|
||
t.Cleanup(func() {
|
||
ROOT_PATH = previousRoot
|
||
PublishConfigSnapshot(previousSnapshot)
|
||
})
|
||
writeLegacyMigrationFixture(t, ROOT_PATH, map[string]any{
|
||
"domain": "example.test", "webDomain": "mail.example.test",
|
||
"sslType": SSLTypeAutoDNS, "httpsEnabled": 1, "httpPort": 8080,
|
||
})
|
||
expected, err := ReadConfig()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
next := *expected
|
||
next.SSL = SSLConfig{Mode: ManagedSSLModeDefault, Challenge: SSLChallengeDNS, DefaultDirectory: "config/ssl"}
|
||
// 模拟签发期间另一项设置先保存成功,续期不得用旧快照覆盖它。
|
||
live := *expected
|
||
live.HttpPort = 9080
|
||
if err := WriteConfig(&live); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := WriteSSLConfig(expected, &next); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
stored, err := ReadConfig()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if stored.HttpPort != 9080 || next.HttpPort != 9080 || CurrentConfig().HttpPort != 9080 || stored.SSL != next.SSL {
|
||
t.Fatalf("SSL 提交覆盖了其他设置:disk=%d next=%d runtime=%d", stored.HttpPort, next.HttpPort, CurrentConfig().HttpPort)
|
||
}
|
||
stale := *stored
|
||
live = *stored
|
||
live.WebDomain = "changed.example.test"
|
||
if err := WriteConfig(&live); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := WriteSSLConfig(&stale, &stale); !errors.Is(err, ErrGenerationConflict) {
|
||
t.Fatalf("签发期间域名变化必须拒绝旧结果:%v", err)
|
||
}
|
||
}
|
||
|
||
// 审查专用:模拟配置落盘中断,只操作测试创建的临时配置根目录。
|
||
func TestAuditSuccessfulConfigRetryMustSurviveRestart(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeRuntimeFixture(t, root, validRuntimeConfig())
|
||
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
|
||
manager, err := NewConfigManager(root, options)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
current := manager.Snapshot()
|
||
email := current.Email
|
||
email.Policy.MaxMessageBytes = 8 << 20
|
||
payload, err := json.Marshal(email)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
actor := authorizedConfigActor()
|
||
updated, err := manager.Update(context.Background(), UpdateRequest{
|
||
Section: SectionEmail, ExpectedGeneration: current.Generation, Payload: payload, Actor: actor,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
interrupted := errors.New("审查注入的一次性分区提交失败")
|
||
manager.transactions.WithHooks(TransactionHooks{BeforePhase: func(phase TransactionPhase) error {
|
||
if phase == TransactionSectionsCommitted {
|
||
return interrupted
|
||
}
|
||
return nil
|
||
}})
|
||
if _, err := manager.ApplyAuthorized(context.Background(), updated.Generation, actor); !errors.Is(err, interrupted) {
|
||
t.Fatalf("中断前提不成立: %v", err)
|
||
}
|
||
manager.transactions.WithHooks(TransactionHooks{})
|
||
if _, err := manager.ApplyAuthorized(context.Background(), updated.Generation, actor); err != nil {
|
||
t.Fatalf("重试没有成功: %v", err)
|
||
}
|
||
before, err := NewConfigLoader(root, options).Load()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if before.Generation != updated.Generation || before.Email.Policy.MaxMessageBytes != email.Policy.MaxMessageBytes {
|
||
t.Fatal("重试结果未实际持久化")
|
||
}
|
||
restarted, err := NewConfigManager(root, options)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
after := restarted.Snapshot()
|
||
t.Logf("successful_generation=%d before_restart=%d after_restart=%d expected_bytes=%d actual_bytes=%d", updated.Generation, before.Generation, after.Generation, email.Policy.MaxMessageBytes, after.Email.Policy.MaxMessageBytes)
|
||
if after.Generation != before.Generation || after.Email.Policy.MaxMessageBytes != before.Email.Policy.MaxMessageBytes {
|
||
t.Fatal("旧失败事务在启动恢复时撤销了后续已成功落盘的配置")
|
||
}
|
||
}
|
||
|
||
// 记录真实后端候选状态约束,供前端丢失候选代次的复现使用。
|
||
func TestAuditPendingConfigBlocksFurtherUpdates(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)
|
||
}
|
||
current := manager.Snapshot()
|
||
email := current.Email
|
||
email.Policy.MaxMessageBytes = 8 << 20
|
||
payload, err := json.Marshal(email)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
actor := authorizedConfigActor()
|
||
request := UpdateRequest{Section: SectionEmail, ExpectedGeneration: current.Generation, Payload: payload, Actor: actor}
|
||
updated, err := manager.Update(context.Background(), request)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, retryErr := manager.Update(context.Background(), request)
|
||
if !errors.Is(retryErr, ErrGenerationConflict) {
|
||
t.Fatalf("原会话再次保存的后端前提变化: %v", retryErr)
|
||
}
|
||
newActor := actor
|
||
newActor.SessionID = "audit-new-session"
|
||
request.Actor = newActor
|
||
_, newSaveErr := manager.Update(context.Background(), request)
|
||
_, newApplyErr := manager.ApplyAuthorized(context.Background(), updated.Generation, newActor)
|
||
if !errors.Is(newSaveErr, ErrGenerationConflict) || !errors.Is(newApplyErr, ErrAuthorizationRequired) {
|
||
t.Fatalf("重新登录后的后端前提变化: save=%v apply=%v", newSaveErr, newApplyErr)
|
||
}
|
||
t.Logf("active=%d pending=%d retry_conflict=%t new_session_save_conflict=%t new_session_apply_denied=%t", manager.Status().ActiveGeneration, updated.Generation, errors.Is(retryErr, ErrGenerationConflict), errors.Is(newSaveErr, ErrGenerationConflict), errors.Is(newApplyErr, ErrAuthorizationRequired))
|
||
// 原会话保留代次时可以应用,问题不是所有应用请求都无条件失败。
|
||
if _, err := manager.ApplyAuthorized(context.Background(), updated.Generation, actor); err != nil {
|
||
t.Fatalf("原会话应用的对照组失败: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestAuditLegacyMigrationMustPreserveSQLiteLocation(t *testing.T) {
|
||
root := t.TempDir()
|
||
oldRoot := ROOT_PATH
|
||
ROOT_PATH = root
|
||
t.Cleanup(func() { ROOT_PATH = oldRoot })
|
||
sourcePath := filepath.Join(root, "config", "archive", "review.db")
|
||
if err := os.MkdirAll(filepath.Dir(sourcePath), 0700); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
database, err := sql.Open("sqlite", sourcePath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer database.Close()
|
||
if _, err := database.Exec("CREATE TABLE audit_marker (value TEXT); INSERT INTO audit_marker VALUES ('original')"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
writeLegacyMigrationFixture(t, root, map[string]any{
|
||
"logLevel": "info", "domain": "mail.example.test", "webDomain": "mail.example.test",
|
||
"dbType": "sqlite", "dbDSN": "./config/archive/review.db", "isInit": true,
|
||
"sslType": "1", "SSLPublicKeyPath": "./config/ssl/public.crt", "SSLPrivateKeyPath": "./config/ssl/private.key",
|
||
"httpPort": 80, "httpsPort": 443, "httpsEnabled": 1,
|
||
})
|
||
before, err := ReadConfig()
|
||
if err != nil || before.DbDSN != sourcePath {
|
||
t.Fatalf("原配置读取路径前提不成立: %v", err)
|
||
}
|
||
if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
after, err := ReadConfig()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, sourceErr := os.Stat(sourcePath)
|
||
_, targetErr := os.Stat(after.DbDSN)
|
||
target, err := sql.Open("sqlite", after.DbDSN)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer target.Close()
|
||
var marker string
|
||
queryErr := target.QueryRow("SELECT value FROM audit_marker").Scan(&marker)
|
||
t.Logf("source_exists=%t target_missing_before_open=%t same_path=%t marker=%q query_error=%v",
|
||
sourceErr == nil, os.IsNotExist(targetErr), before.DbDSN == after.DbDSN, marker, queryErr)
|
||
if before.DbDSN != after.DbDSN || queryErr != nil || marker != "original" {
|
||
t.Fatal("仅迁移配置不应把已有 SQLite 指向一个新建的空库")
|
||
}
|
||
}
|