package httpapi import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" "git.pcux.cn/xuans/Scert/backend/internal/firewall" "git.pcux.cn/xuans/Scert/backend/internal/setup" ) type fakeSetupDNSVerifier struct { verifyResult setup.DNSVerificationResult verifyErr error checkErr error consumeErr error checked bool consumed bool verifyDeadline time.Time verifyHasDeadline bool } type fakeSetupSecretWriter struct { values map[string]map[string]string deleted []string } func (f *fakeSetupSecretWriter) Put(_ context.Context, reference string, value map[string]string) error { if f.values == nil { f.values = make(map[string]map[string]string) } copyValue := make(map[string]string, len(value)) for key, item := range value { copyValue[key] = item } f.values[reference] = copyValue return nil } func (f *fakeSetupSecretWriter) Delete(_ context.Context, reference string) error { if f.values == nil { return errors.New("secret not found") } if _, ok := f.values[reference]; !ok { return errors.New("secret not found") } delete(f.values, reference) f.deleted = append(f.deleted, reference) return nil } type fakeFirewallManager struct { result firewall.Result openErr error rollbackErr error openedPort int rollbackCall bool } type setupCertificateIssuer func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error) func newSetupCertificateIssuer(t *testing.T, baseDir string, issueErr error) setupCertificateIssuer { t.Helper() if issueErr != nil { return func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error) { return certificate.SavedResource{}, issueErr } } fullChainPath := filepath.Join(baseDir, "fullchain.pem") privateKeyPath := filepath.Join(baseDir, "key.pem") if err := os.WriteFile(fullChainPath, []byte("test certificate"), 0o644); err != nil { t.Fatalf("创建测试文件失败:%v", err) } if err := os.WriteFile(privateKeyPath, []byte("test private key"), 0o600); err != nil { t.Fatalf("创建测试文件失败:%v", err) } return func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error) { return certificate.SavedResource{ FullChainPath: fullChainPath, PrivateKeyPath: privateKeyPath, }, nil } } func (f *fakeFirewallManager) OpenTCP(_ context.Context, port int) (firewall.Result, error) { f.openedPort = port if f.result.Port == 0 { f.result.Port = port } return f.result, f.openErr } func (f *fakeFirewallManager) Rollback(_ context.Context, result firewall.Result) error { f.rollbackCall = true if !result.Created { return errors.New("不应回滚未创建的防火墙规则") } return f.rollbackErr } func (f *fakeSetupDNSVerifier) Verify(ctx context.Context, _ setup.DNSVerificationInput) (setup.DNSVerificationResult, error) { if deadline, ok := ctx.Deadline(); ok { f.verifyDeadline = deadline f.verifyHasDeadline = true } if f.verifyResult.CredentialRef == "" { f.verifyResult = setup.DNSVerificationResult{ CredentialRef: "secret://dns/cloudflare/test", VerificationToken: "verified-token", ExpiresAt: time.Now().UTC().Add(15 * time.Minute), TestReport: setup.DNSVerificationReport{ RecordName: "_acme-challenge.example.com.", Stages: []setup.DNSVerificationStage{ {Name: "创建记录", Status: "completed", Message: "已创建测试记录"}, {Name: "查询记录", Status: "completed", Message: "已确认测试记录"}, {Name: "清理记录", Status: "completed", Message: "已清理测试记录"}, }, }, } } return f.verifyResult, f.verifyErr } func (f *fakeSetupDNSVerifier) Check(token, providerID, credentialRef, testDomain string) error { if f.checkErr != nil { return f.checkErr } if token == "" || providerID == "" || credentialRef == "" || testDomain == "" { return errors.New("DNS 验证参数不完整") } f.checked = true return nil } func (f *fakeSetupDNSVerifier) Consume(token, providerID, credentialRef, testDomain string) error { if f.consumeErr != nil { return f.consumeErr } if err := f.Check(token, providerID, credentialRef, testDomain); err != nil { return err } f.consumed = true return nil } func TestSetupStatusOnlyAllowsLocalAccessBeforeInitialization(t *testing.T) { cfg := config.Default(t.TempDir()) server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, SetupDNS: &fakeSetupDNSVerifier{}}) local := httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil) local.RemoteAddr = "127.0.0.1:53000" localResponse := httptest.NewRecorder() server.Handler().ServeHTTP(localResponse, local) if localResponse.Code != http.StatusOK { t.Fatalf("本机读取初始化状态失败:%d %s", localResponse.Code, localResponse.Body.String()) } if !strings.Contains(localResponse.Body.String(), `"dns_test_available":true`) { t.Fatalf("初始化状态应显示 DNS 在线测试可用:%s", localResponse.Body.String()) } remote := httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil) remote.RemoteAddr = "203.0.113.8:53000" remoteResponse := httptest.NewRecorder() server.Handler().ServeHTTP(remoteResponse, remote) if remoteResponse.Code != http.StatusForbidden { t.Fatalf("远程访问初始化状态应被拒绝:%d", remoteResponse.Code) } } func TestSetupStatusHidesConfigurationAfterInitialization(t *testing.T) { cfg := readyTestConfig(t.TempDir()) cfg.Server.Port = 9004 cfg.Server.HTTPSMode = "proxy" cfg.Web.Hostname = "internal.example.test" cfg.Web.PublicIP = "192.0.2.20" cfg.Paths.Certificate = filepath.Join(t.TempDir(), "private-certificates") cfg.DNS.Provider = "cloudflare" cfg.DNS.VerificationState = "verified" server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}}) request := httptest.NewRequest(http.MethodGet, "/api/v1/setup/status", nil) request.RemoteAddr = "203.0.113.8:53000" response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("初始化完成后的状态接口响应错误:%d %s", response.Code, response.Body.String()) } var payload map[string]any if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatalf("解析初始化状态失败:%v", err) } if payload["initialized"] != true { t.Fatalf("初始化状态不正确:%s", response.Body.String()) } for _, key := range []string{"server_port", "reset_url", "https_mode", "web_hostname", "web_public_ip", "certificate_root", "dns_provider", "dns_verification_state"} { if _, exists := payload[key]; exists { t.Fatalf("初始化完成后不应公开内部字段 %s:%s", key, response.Body.String()) } } } func TestSetupDNSProviderCatalogAndOnlineTest(t *testing.T) { cfg := config.Default(t.TempDir()) verifier := &fakeSetupDNSVerifier{} server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, SetupDNS: verifier}) catalogRequest := httptest.NewRequest(http.MethodGet, "/api/v1/setup/dns/providers", nil) catalogRequest.RemoteAddr = "127.0.0.1:53000" catalogResponse := httptest.NewRecorder() server.Handler().ServeHTTP(catalogResponse, catalogRequest) if catalogResponse.Code != http.StatusOK || !strings.Contains(catalogResponse.Body.String(), "cloudflare") { t.Fatalf("读取 DNS 服务商目录失败:%d %s", catalogResponse.Code, catalogResponse.Body.String()) } payload := `{"provider":"cloudflare","test_domain":"example.com","credentials":{"api_token":"secret-token"}}` testRequest := httptest.NewRequest(http.MethodPost, "/api/v1/setup/dns/test", strings.NewReader(payload)) testRequest.RemoteAddr = "127.0.0.1:53000" testRequest.Header.Set("Content-Type", "application/json") testResponse := httptest.NewRecorder() server.Handler().ServeHTTP(testResponse, testRequest) if testResponse.Code != http.StatusOK { t.Fatalf("DNS 在线测试失败:%d %s", testResponse.Code, testResponse.Body.String()) } if strings.Contains(testResponse.Body.String(), "secret-token") { t.Fatal("DNS 在线测试响应不应返回明文凭据") } if !strings.Contains(testResponse.Body.String(), "test_report") || !strings.Contains(testResponse.Body.String(), "创建记录") || !strings.Contains(testResponse.Body.String(), "清理记录") { t.Fatalf("DNS 在线测试响应应包含阶段报告:%s", testResponse.Body.String()) } if !strings.Contains(testResponse.Body.String(), `"verification_timeout_secs":60`) { t.Fatalf("DNS 在线测试响应应包含当前最大等待时间:%s", testResponse.Body.String()) } } func TestSetupDNSTestUsesShortPermissionVerificationTimeout(t *testing.T) { baseDir := t.TempDir() cfg := config.Default(baseDir) cfg.ACME.PropagationTimeoutSecs = 86400 cfg.ACME.PropagationPollingIntervalSecs = 60 verifier := &fakeSetupDNSVerifier{verifyErr: errors.New("测试 DNS 验证失败")} server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, SetupDNS: verifier}) started := time.Now() request := httptest.NewRequest(http.MethodPost, "/api/v1/setup/dns/test", strings.NewReader(`{ "provider":"cloudflare", "test_domain":"example.com", "credentials":{"api_token":"test-token"} }`)) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusUnprocessableEntity { t.Fatalf("模拟 DNS 验证失败应返回 422,实际为 %d:%s", response.Code, response.Body.String()) } if !strings.Contains(response.Body.String(), `"verification_timeout_secs":60`) { t.Fatalf("DNS 验证失败响应应包含当前最大等待时间:%s", response.Body.String()) } if !verifier.verifyHasDeadline { t.Fatal("DNS 在线测试应收到带截止时间的上下文") } remaining := verifier.verifyDeadline.Sub(started) if remaining < 59*time.Second || remaining > 61*time.Second { t.Fatalf("DNS 在线测试请求超时不应继承 ACME 传播配置:剩余时间=%s", remaining) } } func TestSetupAliyunDNSTestUsesFastAccountAPITimeout(t *testing.T) { baseDir := t.TempDir() cfg := config.Default(baseDir) cfg.ACME.PropagationTimeoutSecs = 86400 cfg.ACME.PropagationPollingIntervalSecs = 60 verifier := &fakeSetupDNSVerifier{verifyErr: errors.New("测试 DNS 验证失败")} server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, SetupDNS: verifier}) started := time.Now() request := httptest.NewRequest(http.MethodPost, "/api/v1/setup/dns/test", strings.NewReader(`{ "provider":"aliyun", "test_domain":"example.com", "credentials":{"access_key_id":"test-id","access_key_secret":"test-secret","managed_zones":"example.com","allowed_domains":"example.com"} }`)) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusUnprocessableEntity { t.Fatalf("模拟阿里云 DNS 验证失败应返回 422,实际为 %d:%s", response.Code, response.Body.String()) } if !strings.Contains(response.Body.String(), `"verification_timeout_secs":6`) { t.Fatalf("阿里云 DNS 验证失败响应应包含 6 秒等待上限:%s", response.Body.String()) } if !verifier.verifyHasDeadline { t.Fatal("阿里云 DNS 在线测试应收到带截止时间的上下文") } remaining := verifier.verifyDeadline.Sub(started) if remaining < 5*time.Second || remaining > 7*time.Second { t.Fatalf("阿里云权限验证不应继承 ACME 传播配置:剩余时间=%s", remaining) } } func TestSetupApplyPersistsConfigurationWithoutPlainPassword(t *testing.T) { baseDir := t.TempDir() cfg := config.Default(baseDir) configPath := filepath.Join(baseDir, "config", "scert.json") verifier := &fakeSetupDNSVerifier{} firewallManager := &fakeFirewallManager{result: firewall.Result{ Backend: "windows", RuleName: "Scert Web TCP 8443", Port: 8443, Created: true, Message: "已创建测试规则", }} server := New(Dependencies{ Config: cfg, ConfigPath: configPath, Repository: &stubDomainRepository{}, SetupDNS: verifier, Firewall: firewallManager, IssueSetupCertificate: newSetupCertificateIssuer(t, baseDir, nil), }) payload := `{ "admin_username":"root-admin", "admin_email":"admin@example.test", "admin_password":"secure1234", "server_port":8443, "email_provider":"mail-api", "email_endpoint":"https://mail.example.test/send", "recipients":["ops@example.test","ops@example.test"], "dns_provider":"cloudflare", "dns_credential_ref":"secret://dns/cloudflare/default", "dns_verification_token":"verified-token", "dns_test_domain":"scert.example.test", "propagation_timeout_secs":1200, "propagation_polling_interval_secs":15, "certificate_root":"` + filepath.ToSlash(filepath.Join(baseDir, "ssl")) + `", "web_hostname":"scert.example.test", "web_enabled":true, "https_mode":"direct" }` request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(payload)) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("提交初始化配置失败:%d %s", response.Code, response.Body.String()) } if !verifier.consumed { t.Fatal("初始化提交前应消费 DNS 在线验证令牌") } if firewallManager.openedPort != 8443 || firewallManager.rollbackCall { t.Fatalf("应只开放主服务端口且成功时不回滚:port=%d rollback=%t", firewallManager.openedPort, firewallManager.rollbackCall) } if !strings.Contains(response.Body.String(), `"firewall"`) || !strings.Contains(response.Body.String(), `"created":true`) { t.Fatalf("初始化响应应返回本机防火墙结果:%s", response.Body.String()) } if !strings.Contains(response.Body.String(), `"reset_url":"https://scert.example.test:8443/reset"`) { t.Fatalf("初始化响应应返回根据 Web 地址生成的密码重置路径:%s", response.Body.String()) } data, err := os.ReadFile(configPath) if err != nil { t.Fatalf("读取保存后的配置失败:%v", err) } if strings.Contains(string(data), "secure1234") { t.Fatal("配置文件不应保存管理员明文密码") } var saved config.Config if err := json.Unmarshal(data, &saved); err != nil { t.Fatalf("解析保存后的配置失败:%v", err) } if !saved.SetupReady || saved.Admin.Username != "root-admin" || saved.Admin.Email != "admin@example.test" || saved.Server.Port != 8443 { t.Fatalf("保存后的初始化配置不正确:%+v", saved) } if len(saved.Notification.Recipients) != 1 { t.Fatalf("通知收件人未去重:%v", saved.Notification.Recipients) } if saved.ACME.PropagationTimeoutSecs != 1200 || saved.ACME.PropagationPollingIntervalSecs != 15 { t.Fatalf("Web 初始化未保存 DNS 传播等待配置:timeout=%d interval=%d", saved.ACME.PropagationTimeoutSecs, saved.ACME.PropagationPollingIntervalSecs) } repeat := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(payload)) repeat.RemoteAddr = "127.0.0.1:53001" repeat.Header.Set("Content-Type", "application/json") repeatResponse := httptest.NewRecorder() server.Handler().ServeHTTP(repeatResponse, repeat) if repeatResponse.Code != http.StatusConflict { t.Fatalf("重复初始化应返回冲突:%d", repeatResponse.Code) } } func TestSetupApplyEncryptsNotificationCredentialsAndPersistsReference(t *testing.T) { baseDir := t.TempDir() cfg := config.Default(baseDir) configPath := filepath.Join(baseDir, "config", "scert.json") verifier := &fakeSetupDNSVerifier{} secrets := &fakeSetupSecretWriter{} server := New(Dependencies{ Config: cfg, ConfigPath: configPath, Repository: &stubDomainRepository{}, SetupDNS: verifier, Secrets: secrets, IssueSetupCertificate: newSetupCertificateIssuer(t, baseDir, nil), }) payload := `{ "admin_username":"root-admin", "admin_email":"admin@example.test", "admin_password":"secure1234", "server_port":8443, "email_http_enabled":true, "email_provider":"mail-api", "email_endpoint":"https://mail.example.test/send", "email_http_auth_mode":"bearer", "email_http_credentials":{"token":"plain-token-value"}, "recipients":["ops@example.test"], "dns_provider":"cloudflare", "dns_credential_ref":"secret://dns/cloudflare/default", "dns_verification_token":"verified-token", "dns_test_domain":"scert.example.test", "certificate_root":"` + filepath.ToSlash(filepath.Join(baseDir, "ssl")) + `", "web_hostname":"scert.example.test", "web_enabled":true, "https_mode":"direct" }` request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(payload)) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("提交带 HTTP 通知凭据的初始化配置失败:%d %s", response.Code, response.Body.String()) } data, err := os.ReadFile(configPath) if err != nil { t.Fatalf("读取初始化配置失败:%v", err) } if strings.Contains(string(data), "plain-token-value") || strings.Contains(string(data), "email_http_credentials") { t.Fatal("普通配置文件不应保存 HTTP 通知明文凭据") } var saved config.Config if err := json.Unmarshal(data, &saved); err != nil { t.Fatalf("解析初始化配置失败:%v", err) } if !strings.HasPrefix(saved.Notification.HTTPCredentialRef, "secret://notification/") { t.Fatalf("初始化配置应保存通知凭据引用:%q", saved.Notification.HTTPCredentialRef) } stored, ok := secrets.values[saved.Notification.HTTPCredentialRef] if !ok || stored["token"] != "plain-token-value" { t.Fatalf("通知凭据未写入敏感信息存储:%v", secrets.values) } } func TestSetupApplyRejectsUnverifiedDNSConfiguration(t *testing.T) { cfg := config.Default(t.TempDir()) verifier := &fakeSetupDNSVerifier{checkErr: errors.New("DNS 验证令牌无效或已经过期,请重新测试")} server := New(Dependencies{Config: cfg, ConfigPath: filepath.Join(t.TempDir(), "scert.json"), Repository: &stubDomainRepository{}, SetupDNS: verifier}) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(`{"dns_provider":"cloudflare"}`)) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "DNS 验证状态无效") { t.Fatalf("未验证 DNS 配置应被拒绝:%d %s", response.Code, response.Body.String()) } } func TestNormalizeNotificationCredentials(t *testing.T) { tests := []struct { name string mode string values map[string]string wantErr bool }{ {name: "bearer", mode: "bearer", values: map[string]string{"token": "token-value"}}, {name: "hmac", mode: "hmac_sha256", values: map[string]string{"secret": "secret-value"}}, {name: "session", mode: "session", values: map[string]string{"account": "test", "password": "password-value"}}, {name: "missing bearer token", mode: "bearer", values: map[string]string{"name": "test"}, wantErr: true}, {name: "missing session password", mode: "session", values: map[string]string{"account": "test"}, wantErr: true}, {name: "unknown mode", mode: "unknown", values: map[string]string{"token": "test"}, wantErr: true}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, err := normalizeNotificationCredentials(test.mode, test.values) if (err != nil) != test.wantErr { t.Fatalf("凭据校验结果不正确:error=%v", err) } }) } } func TestSetupApplyRejectsMissingNotificationCredentials(t *testing.T) { cfg := config.Default(t.TempDir()) verifier := &fakeSetupDNSVerifier{} server := New(Dependencies{ Config: cfg, ConfigPath: filepath.Join(t.TempDir(), "scert.json"), Repository: &stubDomainRepository{}, SetupDNS: verifier, }) payload := `{ "admin_username":"root-admin", "admin_email":"admin@example.test", "admin_password":"secure1234", "server_port":8443, "email_http_enabled":true, "email_endpoint":"https://mail.example.test", "email_http_auth_mode":"bearer", "dns_provider":"cloudflare", "dns_credential_ref":"secret://dns/cloudflare/test", "dns_verification_token":"verified-token", "dns_test_domain":"example.test", "certificate_root":"/ssl", "web_hostname":"scert.example.test", "https_mode":"direct" }` request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(payload)) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "明文凭据或已有凭据引用") { t.Fatalf("缺少 HTTP 通知凭据时应被拒绝:%d %s", response.Code, response.Body.String()) } } func TestSetupApplyRejectsOversizedRequest(t *testing.T) { cfg := config.Default(t.TempDir()) server := New(Dependencies{ Config: cfg, ConfigPath: filepath.Join(t.TempDir(), "scert.json"), Repository: &stubDomainRepository{}, SetupDNS: &fakeSetupDNSVerifier{}, }) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(`{"padding":"`+strings.Repeat("x", setupRequestLimit)+`"}`)) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("超大初始化请求应被拒绝:%d", response.Code) } } func TestSetupDNSTestRejectsRemoteAndOversizedRequests(t *testing.T) { cfg := config.Default(t.TempDir()) server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, SetupDNS: &fakeSetupDNSVerifier{}}) remoteRequest := httptest.NewRequest(http.MethodPost, "/api/v1/setup/dns/test", strings.NewReader(`{"provider":"cloudflare"}`)) remoteRequest.RemoteAddr = "203.0.113.8:53000" remoteRequest.Header.Set("Content-Type", "application/json") remoteResponse := httptest.NewRecorder() server.Handler().ServeHTTP(remoteResponse, remoteRequest) if remoteResponse.Code != http.StatusForbidden { t.Fatalf("远程 DNS 测试请求应被拒绝:%d %s", remoteResponse.Code, remoteResponse.Body.String()) } oversizedRequest := httptest.NewRequest(http.MethodPost, "/api/v1/setup/dns/test", strings.NewReader(`{"provider":"cloudflare","padding":"`+strings.Repeat("x", setupRequestLimit)+`"}`)) oversizedRequest.RemoteAddr = "127.0.0.1:53000" oversizedRequest.Header.Set("Content-Type", "application/json") oversizedResponse := httptest.NewRecorder() server.Handler().ServeHTTP(oversizedResponse, oversizedRequest) if oversizedResponse.Code != http.StatusBadRequest { t.Fatalf("超大 DNS 测试请求应被拒绝:%d %s", oversizedResponse.Code, oversizedResponse.Body.String()) } if !strings.Contains(oversizedResponse.Body.String(), `"verification_timeout_secs":60`) { t.Fatalf("DNS 参数错误响应应包含当前最大等待时间:%s", oversizedResponse.Body.String()) } } func TestSetupDNSTestReturnsNotImplementedForUnavailableProvider(t *testing.T) { cfg := config.Default(t.TempDir()) verifier := &fakeSetupDNSVerifier{verifyErr: dnsprovider.ErrProviderUnavailable} server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, SetupDNS: verifier}) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup/dns/test", strings.NewReader(`{"provider":"aliyun","test_domain":"example.com","credentials":{"access_key_id":"id","access_key_secret":"secret"}}`)) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusNotImplemented || !strings.Contains(response.Body.String(), "适配器尚未开放") { t.Fatalf("未开放 DNS Provider 应返回 501:%d %s", response.Code, response.Body.String()) } if strings.Contains(response.Body.String(), "access_key_secret") || strings.Contains(response.Body.String(), "secret") { t.Fatal("未开放 Provider 响应不应包含提交的凭据") } if !strings.Contains(response.Body.String(), `"verification_timeout_secs":6`) { t.Fatalf("未开放 Provider 响应应包含当前最大等待时间:%s", response.Body.String()) } } func TestSetupValidationFailureDoesNotConsumeDNSToken(t *testing.T) { baseDir := t.TempDir() verifier := &fakeSetupDNSVerifier{} server := New(Dependencies{ Config: config.Default(baseDir), ConfigPath: filepath.Join(baseDir, "config", "scert.json"), Repository: &stubDomainRepository{}, SetupDNS: verifier, }) payload := setupPayload(baseDir, "short") request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(payload)) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("无效初始化配置应被拒绝:%d %s", response.Code, response.Body.String()) } if !verifier.checked || verifier.consumed { t.Fatalf("配置校验失败时应检查但不消费 DNS 令牌:checked=%t consumed=%t", verifier.checked, verifier.consumed) } } func TestSetupSaveFailureDoesNotConsumeDNSToken(t *testing.T) { baseDir := t.TempDir() blockedParent := filepath.Join(baseDir, "blocked") if err := os.WriteFile(blockedParent, []byte("占位"), 0o600); err != nil { t.Fatalf("创建阻塞路径失败:%v", err) } verifier := &fakeSetupDNSVerifier{} firewallManager := &fakeFirewallManager{result: firewall.Result{ Backend: "ufw", RuleName: "Scert Web TCP 8443", Port: 8443, Created: true, }} server := New(Dependencies{ Config: config.Default(baseDir), ConfigPath: filepath.Join(blockedParent, "scert.json"), Repository: &stubDomainRepository{}, SetupDNS: verifier, Firewall: firewallManager, IssueSetupCertificate: newSetupCertificateIssuer(t, baseDir, nil), }) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(setupPayload(baseDir, "secure1234"))) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusInternalServerError { t.Fatalf("配置保存失败应返回服务器错误:%d %s", response.Code, response.Body.String()) } if !verifier.checked || verifier.consumed { t.Fatalf("配置保存失败时应检查但不消费 DNS 令牌:checked=%t consumed=%t", verifier.checked, verifier.consumed) } if !firewallManager.rollbackCall { t.Fatal("配置保存失败时应回滚本次新建的防火墙规则") } } func TestSetupSaveFailureKeepsExistingFirewallRule(t *testing.T) { baseDir := t.TempDir() blockedParent := filepath.Join(baseDir, "blocked") if err := os.WriteFile(blockedParent, []byte("占位"), 0o600); err != nil { t.Fatalf("创建阻塞路径失败:%v", err) } firewallManager := &fakeFirewallManager{result: firewall.Result{ Backend: "ufw", RuleName: "Scert Web TCP 8443", Port: 8443, Created: false, }} server := New(Dependencies{ Config: config.Default(baseDir), ConfigPath: filepath.Join(blockedParent, "scert.json"), Repository: &stubDomainRepository{}, SetupDNS: &fakeSetupDNSVerifier{}, Firewall: firewallManager, IssueSetupCertificate: newSetupCertificateIssuer(t, baseDir, nil), }) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(setupPayload(baseDir, "secure1234"))) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusInternalServerError { t.Fatalf("配置保存失败应返回服务器错误:%d %s", response.Code, response.Body.String()) } if firewallManager.rollbackCall { t.Fatal("配置保存失败时不得删除初始化前已经存在的防火墙规则") } } func TestSetupFirewallFailureDoesNotSaveOrConsumeToken(t *testing.T) { baseDir := t.TempDir() configPath := filepath.Join(baseDir, "config", "scert.json") verifier := &fakeSetupDNSVerifier{} firewallManager := &fakeFirewallManager{openErr: errors.New("access denied")} server := New(Dependencies{ Config: config.Default(baseDir), ConfigPath: configPath, Repository: &stubDomainRepository{}, SetupDNS: verifier, Firewall: firewallManager, IssueSetupCertificate: newSetupCertificateIssuer(t, baseDir, nil), }) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(setupPayload(baseDir, "secure1234"))) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusInternalServerError || !strings.Contains(response.Body.String(), "管理员权限") { t.Fatalf("防火墙配置失败应返回明确错误:%d %s", response.Code, response.Body.String()) } if verifier.consumed { t.Fatal("防火墙配置失败时不得消费 DNS 验证令牌") } if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("防火墙配置失败时不得保存初始化配置:%v", err) } } func TestSetupLateFailureDeletesNewNotificationCredentials(t *testing.T) { baseDir := t.TempDir() blockedParent := filepath.Join(baseDir, "blocked") if err := os.WriteFile(blockedParent, []byte("blocked"), 0o600); err != nil { t.Fatalf("create blocked path: %v", err) } secrets := &fakeSetupSecretWriter{} firewallManager := &fakeFirewallManager{result: firewall.Result{ Backend: "ufw", RuleName: "Scert Web TCP 8443", Port: 8443, Created: true, }} server := New(Dependencies{ Config: config.Default(baseDir), ConfigPath: filepath.Join(blockedParent, "scert.json"), Repository: &stubDomainRepository{}, SetupDNS: &fakeSetupDNSVerifier{}, Secrets: secrets, Firewall: firewallManager, IssueSetupCertificate: newSetupCertificateIssuer(t, baseDir, nil), }) payload := strings.Replace(setupPayload(baseDir, "secure1234"), `"server_port":8443,`, `"server_port":8443, "email_http_enabled":true, "email_provider":"mail-api", "email_endpoint":"https://mail.example.test/send", "email_http_auth_mode":"bearer", "email_http_credentials":{"token":"http-secret"}, "email_smtp_enabled":true, "email_smtp_host":"smtp.example.test", "email_smtp_port":587, "email_smtp_username":"smtp-user", "email_smtp_password":"smtp-secret", "email_smtp_mode":"starttls",`, 1) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(payload)) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusInternalServerError { t.Fatalf("late setup failure should return 500: %d %s", response.Code, response.Body.String()) } if len(secrets.deleted) != 2 || len(secrets.values) != 0 { t.Fatalf("new notification credentials should be deleted: deleted=%v values=%v", secrets.deleted, secrets.values) } } func setupPayload(baseDir, password string) string { return `{ "admin_username":"root-admin", "admin_email":"admin@example.test", "admin_password":"` + password + `", "server_port":8443, "dns_provider":"cloudflare", "dns_credential_ref":"secret://dns/cloudflare/test", "dns_verification_token":"verified-token", "dns_test_domain":"scert.example.test", "certificate_root":"` + filepath.ToSlash(filepath.Join(baseDir, "ssl")) + `", "web_hostname":"scert.example.test", "web_enabled":true, "https_mode":"direct" }` } func TestSetupCertificateFailureDoesNotSaveOrConsumeDNSToken(t *testing.T) { baseDir := t.TempDir() configPath := filepath.Join(baseDir, "config", "scert.json") verifier := &fakeSetupDNSVerifier{} server := New(Dependencies{ Config: config.Default(baseDir), ConfigPath: configPath, Repository: &stubDomainRepository{}, SetupDNS: verifier, IssueSetupCertificate: newSetupCertificateIssuer(t, baseDir, errors.New("模拟证书申请失败")), }) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(setupPayload(baseDir, "secure1234"))) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusBadGateway || !strings.Contains(response.Body.String(), "申请管理后台证书失败") { t.Fatalf("证书申请失败应返回明确错误:%d %s", response.Code, response.Body.String()) } if !verifier.checked || verifier.consumed { t.Fatalf("证书申请失败时应检查但不消费 DNS 令牌:checked=%t consumed=%t", verifier.checked, verifier.consumed) } if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("证书申请失败时不得保存初始化配置:%v", err) } } func TestSetupCertificateFileValidationDoesNotSaveOrConsumeDNSToken(t *testing.T) { baseDir := t.TempDir() configPath := filepath.Join(baseDir, "config", "scert.json") verifier := &fakeSetupDNSVerifier{} issuer := func(context.Context, certificate.SetupCertificateRequest) (certificate.SavedResource, error) { return certificate.SavedResource{ FullChainPath: filepath.Join(baseDir, "missing-fullchain.pem"), PrivateKeyPath: filepath.Join(baseDir, "missing-key.pem"), }, nil } server := New(Dependencies{ Config: config.Default(baseDir), ConfigPath: configPath, Repository: &stubDomainRepository{}, SetupDNS: verifier, IssueSetupCertificate: issuer, }) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(setupPayload(baseDir, "secure1234"))) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusInternalServerError || !strings.Contains(response.Body.String(), "管理后台证书文件校验失败") { t.Fatalf("证书文件校验失败应返回明确错误:%d %s", response.Code, response.Body.String()) } if verifier.consumed { t.Fatal("证书文件校验失败时不得消费 DNS 令牌") } if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("证书文件校验失败时不得保存初始化配置:%v", err) } } func TestSetupWithPublicIPRejectsDNSAutomaticCertificate(t *testing.T) { baseDir := t.TempDir() configPath := filepath.Join(baseDir, "config", "scert.json") verifier := &fakeSetupDNSVerifier{} server := New(Dependencies{ Config: config.Default(baseDir), ConfigPath: configPath, Repository: &stubDomainRepository{}, SetupDNS: verifier, IssueSetupCertificate: newSetupCertificateIssuer(t, baseDir, nil), }) payload := strings.Replace(setupPayload(baseDir, "secure1234"), `"web_hostname":"scert.example.test",`, `"web_hostname":"","web_public_ip":"192.0.2.10",`, 1) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(payload)) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "公网 IP 暂不支持") { t.Fatalf("公网 IP 自动申请应被拒绝:%d %s", response.Code, response.Body.String()) } if verifier.consumed { t.Fatal("公网 IP 场景不得消费 DNS 令牌") } if _, err := os.Stat(configPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("公网 IP 场景不得保存初始化配置:%v", err) } } func TestSetupFirewallFailureRollsBackIssuedCertificate(t *testing.T) { baseDir := t.TempDir() verifier := &fakeSetupDNSVerifier{} firewallManager := &fakeFirewallManager{openErr: errors.New("access denied")} rolledBack := false server := New(Dependencies{ Config: config.Default(baseDir), ConfigPath: filepath.Join(baseDir, "config", "scert.json"), Repository: &stubDomainRepository{}, SetupDNS: verifier, Firewall: firewallManager, IssueSetupCertificate: newSetupCertificateIssuer(t, baseDir, nil), RollbackSetupCertificate: func(_ context.Context, saved certificate.SavedResource) error { rolledBack = true return certificate.RollbackSavedResource(context.Background(), saved) }, }) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(setupPayload(baseDir, "secure1234"))) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusInternalServerError { t.Fatalf("防火墙失败应返回服务器错误:%d %s", response.Code, response.Body.String()) } if !rolledBack { t.Fatal("防火墙配置失败时应回滚已保存的初始化证书") } } func TestSetupFailureDeletesTemporaryDNSCredential(t *testing.T) { baseDir := t.TempDir() configPath := filepath.Join(baseDir, "config", "scert.json") verifier := &fakeSetupDNSVerifier{} secrets := &fakeSetupSecretWriter{values: map[string]map[string]string{ "secret://dns/cloudflare/test": {"api_token": "temporary-token"}, }} server := New(Dependencies{ Config: config.Default(baseDir), ConfigPath: configPath, Repository: &stubDomainRepository{}, SetupDNS: verifier, Secrets: secrets, IssueSetupCertificate: newSetupCertificateIssuer(t, baseDir, errors.New("simulated certificate failure")), }) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(setupPayload(baseDir, "secure1234"))) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) if response.Code != http.StatusBadGateway { t.Fatalf("setup certificate failure should return 502: %d %s", response.Code, response.Body.String()) } if _, ok := secrets.values["secret://dns/cloudflare/test"]; ok { t.Fatal("temporary DNS credential should be deleted after setup failure") } if len(secrets.deleted) != 1 || secrets.deleted[0] != "secret://dns/cloudflare/test" { t.Fatalf("unexpected deleted DNS credentials: %v", secrets.deleted) } } func TestSetupApplyDoesNotExposeInternalDNSError(t *testing.T) { baseDir := t.TempDir() configPath := filepath.Join(baseDir, "scert.json") verifier := &fakeSetupDNSVerifier{checkErr: errors.New("读取 /etc/scert/secrets/dns.json 失败:permission denied")} server := New(Dependencies{ Config: config.Default(baseDir), ConfigPath: configPath, Repository: &stubDomainRepository{}, SetupDNS: verifier, }) request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(`{"dns_provider":"cloudflare"}`)) request.RemoteAddr = "127.0.0.1:53000" request.Header.Set("Content-Type", "application/json") response := httptest.NewRecorder() server.Handler().ServeHTTP(response, request) body := response.Body.String() if response.Code != http.StatusBadRequest { t.Fatalf("内部 DNS 错误应返回参数错误:%d %s", response.Code, body) } if strings.Contains(body, "/etc/scert/secrets/dns.json") || strings.Contains(body, "permission denied") { t.Fatalf("初始化响应泄露了内部 DNS 错误:%s", body) } if !strings.Contains(body, "DNS 验证状态无效") { t.Fatalf("初始化响应缺少稳定 DNS 错误摘要:%s", body) } }