Scert/backend/internal/httpapi/setup_test.go

404 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/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
}
type fakeFirewallManager struct {
result firewall.Result
openErr error
rollbackErr error
openedPort int
rollbackCall bool
}
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(context.Context, setup.DNSVerificationInput) (setup.DNSVerificationResult, error) {
if f.verifyResult.CredentialRef == "" {
f.verifyResult = setup.DNSVerificationResult{
CredentialRef: "secret://dns/cloudflare/test",
VerificationToken: "verified-token",
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
}
}
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 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 在线测试响应不应返回明文凭据")
}
}
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,
})
payload := `{
"admin_username":"root-admin",
"admin_email":"admin@example.test",
"admin_password":"secure-pass-123",
"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",
"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/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), "secure-pass-123") {
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)
}
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 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 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())
}
}
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 响应不应包含提交的凭据")
}
}
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,
})
request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(setupPayload(baseDir, "secure-pass-123")))
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,
})
request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(setupPayload(baseDir, "secure-pass-123")))
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,
})
request := httptest.NewRequest(http.MethodPost, "/api/v1/setup", strings.NewReader(setupPayload(baseDir, "secure-pass-123")))
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 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"
}`
}