收紧初始化和SSH接口请求边界

This commit is contained in:
xuans 2026-08-30 20:32:49 +08:00
parent 5f7930ce35
commit fb8e6217b9
9 changed files with 175 additions and 84 deletions

View File

@ -13,7 +13,6 @@ import (
"time"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
@ -48,22 +47,22 @@ func TestCertificateReadAPIs(t *testing.T) {
versions: []certificate.SavedResource{{Version: "20260827-120000.000000000"}},
}
repo := &stubDomainRepository{items: []domain.Domain{{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSAN}}}
server := New(Dependencies{Config: config.Default(t.TempDir()), Repository: repo, CurrentCertificate: reader})
server := New(Dependencies{Config: readyTestConfig(t.TempDir()), Repository: repo, CurrentCertificate: reader})
list := httptest.NewRecorder()
server.Handler().ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil))
serveAdminHTTP(server, list, httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil))
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "example.com") {
t.Fatalf("证书列表响应错误:%d %s", list.Code, list.Body.String())
}
detail := httptest.NewRecorder()
server.Handler().ServeHTTP(detail, httptest.NewRequest(http.MethodGet, "/api/v1/certificates/domain-1", nil))
serveAdminHTTP(server, detail, httptest.NewRequest(http.MethodGet, "/api/v1/certificates/domain-1", nil))
if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), "20260827-120000.000000000") {
t.Fatalf("证书详情响应错误:%d %s", detail.Code, detail.Body.String())
}
versions := httptest.NewRecorder()
server.Handler().ServeHTTP(versions, httptest.NewRequest(http.MethodGet, "/api/v1/domains/domain-1/certificate/versions", nil))
serveAdminHTTP(server, versions, httptest.NewRequest(http.MethodGet, "/api/v1/domains/domain-1/certificate/versions", nil))
if versions.Code != http.StatusOK || !strings.Contains(versions.Body.String(), "20260827-120000.000000000") {
t.Fatalf("证书历史版本响应错误:%d %s", versions.Code, versions.Body.String())
}
@ -87,7 +86,7 @@ func TestCertificateImportAPIAcceptsMultipartFiles(t *testing.T) {
return certificate.IssueResult{DomainID: request.DomainID}, deployment.Report{Status: "success"}, nil
}
server := New(Dependencies{
Config: config.Default(t.TempDir()),
Config: readyTestConfig(t.TempDir()),
Repository: &stubDomainRepository{},
ImportCertificate: importer,
})
@ -110,7 +109,7 @@ func TestCertificateImportAPIAcceptsMultipartFiles(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/import", body)
request.Header.Set("Content-Type", writer.FormDataContentType())
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
serveAdminHTTP(server, response, request)
if response.Code != http.StatusOK || received.DomainID != "domain-1" {
t.Fatalf("证书导入响应错误:%d %s", response.Code, response.Body.String())
}
@ -121,7 +120,7 @@ func TestCertificateImportAPIAcceptsMultipartFiles(t *testing.T) {
func TestCertificateImportAPIRejectsMissingFiles(t *testing.T) {
server := New(Dependencies{
Config: config.Default(t.TempDir()),
Config: readyTestConfig(t.TempDir()),
Repository: &stubDomainRepository{},
ImportCertificate: func(context.Context, certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) {
return certificate.IssueResult{}, deployment.Report{}, nil
@ -131,7 +130,7 @@ func TestCertificateImportAPIRejectsMissingFiles(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/import", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
serveAdminHTTP(server, response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("缺少证书路径应返回参数错误:%d %s", response.Code, response.Body.String())
}

View File

@ -43,7 +43,10 @@ func (s *Server) requireAdmin(c *gin.Context) {
setupReady := s.config.SetupReady
s.configMu.RUnlock()
if !setupReady {
c.Next()
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
"error": "请先完成初始化配置",
"request_id": requestIDFromContext(c),
})
return
}
session, ok := s.currentAuthSession(c)

View File

@ -116,3 +116,30 @@ func TestAdminAPIRejectsAuthenticatedRegularUser(t *testing.T) {
t.Fatalf("普通用户权限错误提示不明确:%s", response.Body.String())
}
}
func TestAdminAPIRequiresSetupBeforeAccessingManagementHandlers(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg.SetupReady = false
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}})
paths := []string{
"/api/v1/domains",
"/api/v1/settings",
"/api/v1/servers",
"/api/v1/tasks",
"/api/v1/audit-logs",
"/api/v1/certificates",
"/api/v1/ssh-keys",
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
if response.Code != http.StatusConflict {
t.Fatalf("初始化前访问 %s 应返回 409,实际为 %d:%s", path, response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "请先完成初始化配置") {
t.Fatalf("初始化前响应缺少中文提示:%s", response.Body.String())
}
})
}
}

View File

@ -64,10 +64,10 @@ func (r *stubDomainRepository) Update(_ context.Context, item domain.Domain) err
}
func TestStatusReturnsApplicationAndConfigVersions(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg := readyTestConfig(t.TempDir())
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}})
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/status", nil))
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, "/api/v1/status", nil))
if response.Code != http.StatusOK {
t.Fatalf("状态接口响应错误:%d", response.Code)
}
@ -84,14 +84,14 @@ func TestStatusReturnsApplicationAndConfigVersions(t *testing.T) {
}
func TestDomainAPIValidatesSANAndHidesInternalErrors(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg := readyTestConfig(t.TempDir())
repo := &stubDomainRepository{}
server := New(Dependencies{Config: cfg, Repository: repo})
invalid := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"san","sans":["bad_domain"]}`))
invalid.Header.Set("Content-Type", "application/json")
invalidResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(invalidResponse, invalid)
serveAdminHTTP(server, invalidResponse, invalid)
if invalidResponse.Code != http.StatusBadRequest || strings.Contains(invalidResponse.Body.String(), "detail") {
t.Fatalf("非法 SAN 响应不正确:%d %s", invalidResponse.Code, invalidResponse.Body.String())
}
@ -99,14 +99,14 @@ func TestDomainAPIValidatesSANAndHidesInternalErrors(t *testing.T) {
valid := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"san","sans":["www.example.com"]}`))
valid.Header.Set("Content-Type", "application/json")
validResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(validResponse, valid)
serveAdminHTTP(server, validResponse, valid)
if validResponse.Code != http.StatusCreated || len(repo.created.SANs) != 2 || repo.created.SANs[0] != "example.com" {
t.Fatalf("合法 SAN 响应不正确:%d %s", validResponse.Code, validResponse.Body.String())
}
repo.listErr = errors.New("internal-dsn-secret")
listResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(listResponse, httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil))
serveAdminHTTP(server, listResponse, httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil))
if listResponse.Code != http.StatusInternalServerError {
t.Fatalf("列表错误状态码不正确:%d", listResponse.Code)
}
@ -116,25 +116,25 @@ func TestDomainAPIValidatesSANAndHidesInternalErrors(t *testing.T) {
}
func TestDeleteMissingDomainReturnsNotFound(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg := readyTestConfig(t.TempDir())
repo := &stubDomainRepository{deleteErr: sql.ErrNoRows}
server := New(Dependencies{Config: cfg, Repository: repo})
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodDelete, "/api/v1/domains/missing", nil))
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodDelete, "/api/v1/domains/missing", nil))
if response.Code != http.StatusNotFound || strings.Contains(response.Body.String(), "detail") {
t.Fatalf("删除不存在域名响应不正确:%d %s", response.Code, response.Body.String())
}
}
func TestUpdateDomainAPI(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg := readyTestConfig(t.TempDir())
loaded := domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle, SANs: []string{"example.com"}, Status: "active"}
repo := &stubDomainRepository{loaded: loaded}
server := New(Dependencies{Config: cfg, Repository: repo})
request := httptest.NewRequest(http.MethodPut, "/api/v1/domains/domain-1", strings.NewReader(`{"name":"example.com","certificate_type":"san","sans":["www.example.com"],"auto_renew":false,"acme_environment":"production","dns_provider":"cloudflare"}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
serveAdminHTTP(server, response, request)
if response.Code != http.StatusOK {
t.Fatalf("更新域名响应错误:%d %s", response.Code, response.Body.String())
}
@ -144,33 +144,33 @@ func TestUpdateDomainAPI(t *testing.T) {
}
func TestCreateDomainConflictReturnsConflict(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg := readyTestConfig(t.TempDir())
repo := &stubDomainRepository{createErr: errors.New("UNIQUE constraint failed: domains.name")}
server := New(Dependencies{Config: cfg, Repository: repo})
request := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"single"}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
serveAdminHTTP(server, response, request)
if response.Code != http.StatusConflict {
t.Fatalf("重复域名响应错误:%d %s", response.Code, response.Body.String())
}
}
func TestUpdateDomainRejectsInvalidEnvironment(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg := readyTestConfig(t.TempDir())
loaded := domain.Domain{ID: "domain-1", Name: "example.com"}
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{loaded: loaded}})
request := httptest.NewRequest(http.MethodPut, "/api/v1/domains/domain-1", strings.NewReader(`{"name":"example.com","certificate_type":"single","acme_environment":"invalid"}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
serveAdminHTTP(server, response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("非法 ACME 环境响应错误:%d %s", response.Code, response.Body.String())
}
}
func TestIssueDomainUsesCertificateWorkflow(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg := readyTestConfig(t.TempDir())
calledID := ""
server := New(Dependencies{
Config: cfg, Repository: &stubDomainRepository{},
@ -180,7 +180,7 @@ func TestIssueDomainUsesCertificateWorkflow(t *testing.T) {
},
})
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
if response.Code != http.StatusOK || calledID != "domain-1" || !strings.Contains(response.Body.String(), `"status":"success"`) {
t.Fatalf("证书申请接口响应错误:%d %s", response.Code, response.Body.String())
}

View File

@ -27,7 +27,7 @@ import (
func TestHealthAndDomainAPI(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg := readyTestConfig(dir)
cfg.Database.DSN = filepath.Join(dir, "data", "test.db")
db, err := database.Open(context.Background(), cfg.Database)
if err != nil {
@ -45,7 +45,7 @@ func TestHealthAndDomainAPI(t *testing.T) {
server := New(Dependencies{Config: cfg, Logger: logger, Repository: repository.NewSQLDomainRepository(db.DB)})
health := httptest.NewRecorder()
server.Handler().ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
serveAdminHTTP(server, health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if health.Code != http.StatusOK || !strings.Contains(health.Body.String(), `"status":"ok"`) {
t.Fatalf("健康检查响应错误:%d %s", health.Code, health.Body.String())
}
@ -53,13 +53,13 @@ func TestHealthAndDomainAPI(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"single"}`))
request.Header.Set("Content-Type", "application/json")
created := httptest.NewRecorder()
server.Handler().ServeHTTP(created, request)
serveAdminHTTP(server, created, request)
if created.Code != http.StatusCreated {
t.Fatalf("创建域名响应错误:%d %s", created.Code, created.Body.String())
}
list := httptest.NewRecorder()
server.Handler().ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil))
serveAdminHTTP(server, list, httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil))
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "example.com") {
t.Fatalf("域名列表响应错误:%d %s", list.Code, list.Body.String())
}
@ -67,7 +67,7 @@ func TestHealthAndDomainAPI(t *testing.T) {
func TestResetPathRequiresScertAdminActivation(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg := readyTestConfig(dir)
cfg.SetupReady = true
cfg.Web.Hostname = "scert.example.test"
cfg.Server.Port = 8443
@ -75,7 +75,7 @@ func TestResetPathRequiresScertAdminActivation(t *testing.T) {
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, ResetStatePath: statePath})
inactive := httptest.NewRecorder()
server.Handler().ServeHTTP(inactive, httptest.NewRequest(http.MethodGet, "/reset", nil))
serveAdminHTTP(server, inactive, httptest.NewRequest(http.MethodGet, "/reset", nil))
if inactive.Code != http.StatusNotFound {
t.Fatalf("Scert-admin 未启动时 /reset 应返回 404:%d", inactive.Code)
}
@ -85,7 +85,7 @@ func TestResetPathRequiresScertAdminActivation(t *testing.T) {
t.Fatalf("启用测试密码重置入口失败:%v", err)
}
active := httptest.NewRecorder()
server.Handler().ServeHTTP(active, httptest.NewRequest(http.MethodGet, "/reset", nil))
serveAdminHTTP(server, active, httptest.NewRequest(http.MethodGet, "/reset", nil))
if active.Code != http.StatusOK || !strings.Contains(active.Body.String(), "超级管理员密码重置") {
t.Fatalf("Scert-admin 启用后 /reset 页面错误:%d %s", active.Code, active.Body.String())
}
@ -96,7 +96,7 @@ func TestResetPathRequiresScertAdminActivation(t *testing.T) {
t.Fatalf("关闭测试密码重置入口失败:%v", err)
}
stopped := httptest.NewRecorder()
server.Handler().ServeHTTP(stopped, httptest.NewRequest(http.MethodGet, "/reset", nil))
serveAdminHTTP(server, stopped, httptest.NewRequest(http.MethodGet, "/reset", nil))
if stopped.Code != http.StatusNotFound {
t.Fatalf("Scert-admin 停止后 /reset 应恢复 404:%d", stopped.Code)
}
@ -104,7 +104,7 @@ func TestResetPathRequiresScertAdminActivation(t *testing.T) {
func TestResetPasswordUpdatesConfigAndConsumesSession(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg := readyTestConfig(dir)
cfg.SetupReady = true
cfg.Web.Hostname = "scert.example.test"
cfg.DNS.VerificationState = "verified"
@ -126,7 +126,7 @@ func TestResetPasswordUpdatesConfigAndConsumesSession(t *testing.T) {
}
server := New(Dependencies{Config: cfg, ConfigPath: configPath, Repository: &stubDomainRepository{}, ResetStatePath: admin.StatePath(cfg.Paths.Data)})
get := httptest.NewRecorder()
server.Handler().ServeHTTP(get, httptest.NewRequest(http.MethodGet, "http://scert.example.test/reset", nil))
serveAdminHTTP(server, get, httptest.NewRequest(http.MethodGet, "http://scert.example.test/reset", nil))
if get.Code != http.StatusOK {
t.Fatalf("获取密码重置页面失败:%d", get.Code)
}
@ -142,7 +142,7 @@ func TestResetPasswordUpdatesConfigAndConsumesSession(t *testing.T) {
request.Header.Set("Origin", "http://scert.example.test")
request.AddCookie(cookie)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
serveAdminHTTP(server, response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "密码重置成功") {
t.Fatalf("密码重置响应错误:%d %s", response.Code, response.Body.String())
}
@ -164,7 +164,7 @@ func TestResetPasswordUpdatesConfigAndConsumesSession(t *testing.T) {
t.Fatal("旧密码不应继续有效")
}
second := httptest.NewRecorder()
server.Handler().ServeHTTP(second, httptest.NewRequest(http.MethodGet, "http://scert.example.test/reset", nil))
serveAdminHTTP(server, second, httptest.NewRequest(http.MethodGet, "http://scert.example.test/reset", nil))
if second.Code != http.StatusNotFound {
t.Fatalf("密码重置成功后入口必须关闭:%d", second.Code)
}
@ -175,7 +175,7 @@ func TestResetPasswordUpdatesConfigAndConsumesSession(t *testing.T) {
func TestResetPasswordSynchronizesPrimarySuperAdminUser(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg := readyTestConfig(dir)
cfg.SetupReady = true
cfg.Web.Hostname = "scert.example.test"
cfg.DNS.VerificationState = "verified"
@ -215,7 +215,7 @@ func TestResetPasswordSynchronizesPrimarySuperAdminUser(t *testing.T) {
ResetStatePath: admin.StatePath(cfg.Paths.Data),
})
get := httptest.NewRecorder()
server.Handler().ServeHTTP(get, httptest.NewRequest(http.MethodGet, "https://scert.example.test/reset", nil))
serveAdminHTTP(server, get, httptest.NewRequest(http.MethodGet, "https://scert.example.test/reset", nil))
if get.Code != http.StatusOK {
t.Fatalf("获取密码重置页面失败:%d", get.Code)
}
@ -229,7 +229,7 @@ func TestResetPasswordSynchronizesPrimarySuperAdminUser(t *testing.T) {
request.Header.Set("Origin", "https://scert.example.test")
request.AddCookie(cookies[0])
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
serveAdminHTTP(server, response, request)
if response.Code != http.StatusOK {
t.Fatalf("密码重置失败:%d %s", response.Code, response.Body.String())
}
@ -244,7 +244,7 @@ func TestResetPasswordSynchronizesPrimarySuperAdminUser(t *testing.T) {
func TestResetRejectsCrossSiteAndLimitsFailures(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg := readyTestConfig(dir)
cfg.SetupReady = true
cfg.Web.Hostname = "scert.example.test"
cfg.DNS.VerificationState = "verified"
@ -258,7 +258,7 @@ func TestResetRejectsCrossSiteAndLimitsFailures(t *testing.T) {
}
server := New(Dependencies{Config: cfg, ConfigPath: filepath.Join(dir, "config", "scert.json"), Repository: &stubDomainRepository{}, ResetStatePath: admin.StatePath(cfg.Paths.Data)})
get := httptest.NewRecorder()
server.Handler().ServeHTTP(get, httptest.NewRequest(http.MethodGet, "http://scert.example.test/reset", nil))
serveAdminHTTP(server, get, httptest.NewRequest(http.MethodGet, "http://scert.example.test/reset", nil))
cookie := get.Result().Cookies()[0]
base := url.Values{"csrf_token": {cookie.Value}, "reset_code": {session.Code}, "new_password": {"NewPass1234"}, "confirm_password": {"NewPass1234"}}
for i := 0; i < 5; i++ {
@ -267,7 +267,7 @@ func TestResetRejectsCrossSiteAndLimitsFailures(t *testing.T) {
request.Header.Set("Origin", "http://evil.example.test")
request.AddCookie(cookie)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
serveAdminHTTP(server, response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("跨站请求应被拒绝:第 %d 次状态码为 %d", i+1, response.Code)
}
@ -277,7 +277,7 @@ func TestResetRejectsCrossSiteAndLimitsFailures(t *testing.T) {
request.Header.Set("Origin", "http://scert.example.test")
request.AddCookie(cookie)
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
serveAdminHTTP(server, response, request)
if response.Code != http.StatusTooManyRequests {
t.Fatalf("超过失败次数后应限流:%d", response.Code)
}
@ -285,7 +285,7 @@ func TestResetRejectsCrossSiteAndLimitsFailures(t *testing.T) {
func TestServerAndDomainAssociationAPI(t *testing.T) {
dir := t.TempDir()
cfg := config.Default(dir)
cfg := readyTestConfig(dir)
cfg.Database.DSN = filepath.Join(dir, "data", "servers.db")
db, err := database.Open(context.Background(), cfg.Database)
if err != nil {
@ -312,7 +312,7 @@ func TestServerAndDomainAssociationAPI(t *testing.T) {
domainRequest := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"single"}`))
domainRequest.Header.Set("Content-Type", "application/json")
domainResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(domainResponse, domainRequest)
serveAdminHTTP(server, domainResponse, domainRequest)
if domainResponse.Code != http.StatusCreated {
t.Fatalf("创建测试域名失败:%d %s", domainResponse.Code, domainResponse.Body.String())
}
@ -326,7 +326,7 @@ func TestServerAndDomainAssociationAPI(t *testing.T) {
serverRequest := httptest.NewRequest(http.MethodPost, "/api/v1/servers", strings.NewReader(`{"name":"测试服务器","address":"192.0.2.50","port":22,"username":"deploy","private_key_ref":"`+key.Reference+`","host_key_fingerprint":"SHA256:test","web_server":"nginx"}`))
serverRequest.Header.Set("Content-Type", "application/json")
serverResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(serverResponse, serverRequest)
serveAdminHTTP(server, serverResponse, serverRequest)
if serverResponse.Code != http.StatusCreated {
t.Fatalf("创建测试服务器失败:%d %s", serverResponse.Code, serverResponse.Body.String())
}
@ -341,18 +341,18 @@ func TestServerAndDomainAssociationAPI(t *testing.T) {
associationRequest := httptest.NewRequest(http.MethodPut, "/api/v1/domains/"+item.ID+"/servers", strings.NewReader(`{"server_ids":["`+created.ID+`"]}`))
associationRequest.Header.Set("Content-Type", "application/json")
associationResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(associationResponse, associationRequest)
serveAdminHTTP(server, associationResponse, associationRequest)
if associationResponse.Code != http.StatusOK || !strings.Contains(associationResponse.Body.String(), created.ID) {
t.Fatalf("保存域名服务器关联失败:%d %s", associationResponse.Code, associationResponse.Body.String())
}
deleteResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(deleteResponse, httptest.NewRequest(http.MethodDelete, "/api/v1/servers/"+created.ID, nil))
serveAdminHTTP(server, deleteResponse, httptest.NewRequest(http.MethodDelete, "/api/v1/servers/"+created.ID, nil))
if deleteResponse.Code != http.StatusNoContent {
t.Fatalf("删除测试服务器失败:%d %s", deleteResponse.Code, deleteResponse.Body.String())
}
listResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(listResponse, httptest.NewRequest(http.MethodGet, "/api/v1/domains/"+item.ID+"/servers", nil))
serveAdminHTTP(server, listResponse, httptest.NewRequest(http.MethodGet, "/api/v1/domains/"+item.ID+"/servers", nil))
if listResponse.Code != http.StatusOK || strings.Contains(listResponse.Body.String(), created.ID) {
t.Fatalf("服务器删除后域名关联未清理:%d %s", listResponse.Code, listResponse.Body.String())
}
@ -497,7 +497,7 @@ type resetTestFixture struct {
func newResetTestFixture(t *testing.T) resetTestFixture {
t.Helper()
dir := t.TempDir()
cfg := config.Default(dir)
cfg := readyTestConfig(dir)
cfg.SetupReady = true
cfg.Web.Hostname = "scert.example.test"
cfg.DNS.VerificationState = "verified"
@ -519,7 +519,7 @@ func newResetTestFixture(t *testing.T) resetTestFixture {
}
server := New(Dependencies{Config: cfg, ConfigPath: configPath, Repository: &stubDomainRepository{}, ResetStatePath: admin.StatePath(cfg.Paths.Data)})
get := httptest.NewRecorder()
server.Handler().ServeHTTP(get, httptest.NewRequest(http.MethodGet, "https://scert.example.test/reset", nil))
serveAdminHTTP(server, get, httptest.NewRequest(http.MethodGet, "https://scert.example.test/reset", nil))
if get.Code != http.StatusOK {
t.Fatalf("获取测试重置页面失败:%d", get.Code)
}
@ -543,7 +543,7 @@ func resetForm(code, csrfToken, password string) url.Values {
}
func TestListenAddressKeepsDirectModeBinding(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg := readyTestConfig(t.TempDir())
cfg.Server.Host = "0.0.0.0"
cfg.Server.Port = 5200
cfg.Server.HTTPSMode = "direct"
@ -557,7 +557,7 @@ func TestListenAddressKeepsDirectModeBinding(t *testing.T) {
}
func TestListenAddressRestrictsProxyModeToLoopback(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg := readyTestConfig(t.TempDir())
cfg.Server.Host = "0.0.0.0"
cfg.Server.Port = 5201
cfg.Server.HTTPSMode = "proxy"
@ -580,7 +580,7 @@ func TestListenAddressRestrictsProxyModeToLoopback(t *testing.T) {
}
func TestListenAddressAllowsLoopbackProxyBinding(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg := readyTestConfig(t.TempDir())
cfg.Server.Host = "[::1]"
cfg.Server.Port = 5202
cfg.Server.HTTPSMode = "proxy"
@ -594,7 +594,7 @@ func TestListenAddressAllowsLoopbackProxyBinding(t *testing.T) {
}
func TestForwardedHTTPSIsTrustedOnlyInProxyMode(t *testing.T) {
direct := config.Default(t.TempDir())
direct := readyTestConfig(t.TempDir())
direct.Server.HTTPSMode = "direct"
directServer := New(Dependencies{Config: direct, Repository: &stubDomainRepository{}})
directRequest := httptest.NewRequest(http.MethodGet, "http://scert.example.test/healthz", nil)
@ -603,7 +603,7 @@ func TestForwardedHTTPSIsTrustedOnlyInProxyMode(t *testing.T) {
t.Fatal("直接 HTTPS 模式不应信任外部伪造的 X-Forwarded-Proto")
}
proxy := config.Default(t.TempDir())
proxy := readyTestConfig(t.TempDir())
proxy.Server.HTTPSMode = "proxy"
proxyServer := New(Dependencies{Config: proxy, Repository: &stubDomainRepository{}})
proxyRequest := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:5200/healthz", nil)
@ -614,7 +614,7 @@ func TestForwardedHTTPSIsTrustedOnlyInProxyMode(t *testing.T) {
}
func TestProxyModeAcceptsHTTPSSameOrigin(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg := readyTestConfig(t.TempDir())
cfg.SetupReady = true
cfg.Server.HTTPSMode = "proxy"
cfg.Admin.Username = "admin"

View File

@ -33,9 +33,8 @@ func (s *Server) createSSHKey(c *gin.Context) {
s.writeError(c, http.StatusNotImplemented, "SSH 私钥管理接口尚未配置", errors.New("SSH 私钥管理服务未配置"))
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSSHKeyRequestBytes)
var request sshKeyRequest
if err := c.ShouldBindJSON(&request); err != nil {
if err := bindJSON(c, &request, maxSSHKeyRequestBytes); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
s.writeError(c, http.StatusRequestEntityTooLarge, "SSH 私钥请求内容过大", err)

View File

@ -13,7 +13,6 @@ import (
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
@ -22,7 +21,7 @@ import (
func TestSSHKeyAPIHidesPrivateKeyAndBlocksDeletingUsedReference(t *testing.T) {
directory := t.TempDir()
cfg := config.Default(directory)
cfg := readyTestConfig(directory)
cfg.Database.DSN = filepath.Join(directory, "data", "ssh-keys.db")
db, err := database.Open(context.Background(), cfg.Database)
if err != nil {
@ -50,7 +49,7 @@ func TestSSHKeyAPIHidesPrivateKeyAndBlocksDeletingUsedReference(t *testing.T) {
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/ssh-keys", strings.NewReader(string(createBody)))
createRequest.Header.Set("Content-Type", "application/json")
createResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(createResponse, createRequest)
serveAdminHTTP(server, createResponse, createRequest)
if createResponse.Code != http.StatusCreated {
t.Fatalf("创建 SSH 私钥失败:%d %s", createResponse.Code, createResponse.Body.String())
}
@ -63,7 +62,7 @@ func TestSSHKeyAPIHidesPrivateKeyAndBlocksDeletingUsedReference(t *testing.T) {
}
listResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(listResponse, httptest.NewRequest(http.MethodGet, "/api/v1/ssh-keys", nil))
serveAdminHTTP(server, listResponse, httptest.NewRequest(http.MethodGet, "/api/v1/ssh-keys", nil))
if listResponse.Code != http.StatusOK || !strings.Contains(listResponse.Body.String(), key.Reference) || strings.Contains(listResponse.Body.String(), "PRIVATE KEY") {
t.Fatalf("SSH 私钥列表响应无效或泄露敏感内容:%d %s", listResponse.Code, listResponse.Body.String())
}
@ -72,7 +71,7 @@ func TestSSHKeyAPIHidesPrivateKeyAndBlocksDeletingUsedReference(t *testing.T) {
serverRequest := httptest.NewRequest(http.MethodPost, "/api/v1/servers", strings.NewReader(serverBody))
serverRequest.Header.Set("Content-Type", "application/json")
serverResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(serverResponse, serverRequest)
serveAdminHTTP(server, serverResponse, serverRequest)
if serverResponse.Code != http.StatusCreated {
t.Fatalf("创建使用 SSH 私钥的服务器失败:%d %s", serverResponse.Code, serverResponse.Body.String())
}
@ -84,17 +83,17 @@ func TestSSHKeyAPIHidesPrivateKeyAndBlocksDeletingUsedReference(t *testing.T) {
}
deleteUsedResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(deleteUsedResponse, httptest.NewRequest(http.MethodDelete, "/api/v1/ssh-keys/"+key.ID, nil))
serveAdminHTTP(server, deleteUsedResponse, httptest.NewRequest(http.MethodDelete, "/api/v1/ssh-keys/"+key.ID, nil))
if deleteUsedResponse.Code != http.StatusConflict {
t.Fatalf("仍被使用的 SSH 私钥应禁止删除:%d %s", deleteUsedResponse.Code, deleteUsedResponse.Body.String())
}
deleteServerResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(deleteServerResponse, httptest.NewRequest(http.MethodDelete, "/api/v1/servers/"+createdServer.ID, nil))
serveAdminHTTP(server, deleteServerResponse, httptest.NewRequest(http.MethodDelete, "/api/v1/servers/"+createdServer.ID, nil))
if deleteServerResponse.Code != http.StatusNoContent {
t.Fatalf("删除测试服务器失败:%d %s", deleteServerResponse.Code, deleteServerResponse.Body.String())
}
deleteKeyResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(deleteKeyResponse, httptest.NewRequest(http.MethodDelete, "/api/v1/ssh-keys/"+key.ID, nil))
serveAdminHTTP(server, deleteKeyResponse, httptest.NewRequest(http.MethodDelete, "/api/v1/ssh-keys/"+key.ID, nil))
if deleteKeyResponse.Code != http.StatusNoContent {
t.Fatalf("删除未使用的 SSH 私钥失败:%d %s", deleteKeyResponse.Code, deleteKeyResponse.Body.String())
}
@ -105,13 +104,13 @@ func TestSSHKeyAPIRejectsInvalidKeyWithoutEchoingIt(t *testing.T) {
if err != nil {
t.Fatalf("打开敏感信息存储失败:%v", err)
}
server := New(Dependencies{Config: config.Default(t.TempDir()), Repository: &stubDomainRepository{}, SSHKeys: sshkey.NewService(store)})
server := New(Dependencies{Config: readyTestConfig(t.TempDir()), Repository: &stubDomainRepository{}, SSHKeys: sshkey.NewService(store)})
secret := "invalid-private-key-sensitive-content"
body, _ := json.Marshal(sshKeyRequest{Name: "错误私钥", PrivateKey: secret})
request := httptest.NewRequest(http.MethodPost, "/api/v1/ssh-keys", strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
serveAdminHTTP(server, response, request)
if response.Code != http.StatusBadRequest || strings.Contains(response.Body.String(), secret) {
t.Fatalf("无效 SSH 私钥响应不安全:%d %s", response.Code, response.Body.String())
}
@ -122,12 +121,12 @@ func TestSSHKeyAPIRejectsOversizedRequestBody(t *testing.T) {
if err != nil {
t.Fatalf("failed to open secret store: %v", err)
}
server := New(Dependencies{Config: config.Default(t.TempDir()), Repository: &stubDomainRepository{}, SSHKeys: sshkey.NewService(store)})
server := New(Dependencies{Config: readyTestConfig(t.TempDir()), Repository: &stubDomainRepository{}, SSHKeys: sshkey.NewService(store)})
body := `{"name":"oversized request","private_key":"` + strings.Repeat("a", maxSSHKeyRequestBytes) + `"}`
request := httptest.NewRequest(http.MethodPost, "/api/v1/ssh-keys", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
serveAdminHTTP(server, response, request)
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized SSH key request should return 413: %d %s", response.Code, response.Body.String())
}
@ -138,7 +137,7 @@ func TestSSHKeyAPIRejectsOversizedPrivateKeyAndPassphrase(t *testing.T) {
if err != nil {
t.Fatalf("failed to open secret store: %v", err)
}
server := New(Dependencies{Config: config.Default(t.TempDir()), Repository: &stubDomainRepository{}, SSHKeys: sshkey.NewService(store)})
server := New(Dependencies{Config: readyTestConfig(t.TempDir()), Repository: &stubDomainRepository{}, SSHKeys: sshkey.NewService(store)})
requests := []sshKeyRequest{
{Name: "oversized key", PrivateKey: strings.Repeat("a", sshkey.MaxPrivateKeyBytes+1)},
{Name: "oversized passphrase", PrivateKey: testSSHPrivateKey(t), Passphrase: strings.Repeat("p", 4097)},
@ -151,7 +150,7 @@ func TestSSHKeyAPIRejectsOversizedPrivateKeyAndPassphrase(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/ssh-keys", strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
serveAdminHTTP(server, response, request)
leaksPassphrase := input.Passphrase != "" && strings.Contains(response.Body.String(), input.Passphrase)
if response.Code != http.StatusBadRequest || strings.Contains(response.Body.String(), input.PrivateKey) || leaksPassphrase {
t.Fatalf("oversized input should be rejected safely: %d %s", response.Code, response.Body.String())
@ -171,3 +170,48 @@ func testSSHPrivateKey(t *testing.T) string {
}
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded}))
}
func TestSSHKeyAPIRejectsUnknownFieldsAndTrailingJSON(t *testing.T) {
store, err := secretstore.Open(t.TempDir())
if err != nil {
t.Fatalf("打开敏感信息存储失败:%v", err)
}
server := New(Dependencies{Config: readyTestConfig(t.TempDir()), Repository: &stubDomainRepository{}, SSHKeys: sshkey.NewService(store)})
privateKey := testSSHPrivateKey(t)
tests := []struct {
name string
body string
}{
{
name: "未知字段",
body: `{"name":"测试私钥","private_key":` + mustJSONString(t, privateKey) + `,"unexpected":true}`,
},
{
name: "多段 JSON",
body: `{"name":"测试私钥","private_key":` + mustJSONString(t, privateKey) + `}{"name":"第二个私钥","private_key":` + mustJSONString(t, privateKey) + `}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/ssh-keys", strings.NewReader(tt.body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
serveAdminHTTP(server, response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("%s 应返回 400,实际为 %d:%s", tt.name, response.Code, response.Body.String())
}
if strings.Contains(response.Body.String(), privateKey) || strings.Contains(response.Body.String(), "PRIVATE KEY") {
t.Fatalf("%s 响应泄露了私钥:%s", tt.name, response.Body.String())
}
})
}
}
func mustJSONString(t *testing.T, value string) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("编码 JSON 字符串失败:%v", err)
}
return string(encoded)
}

View File

@ -10,7 +10,6 @@ import (
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
@ -19,7 +18,7 @@ import (
func newTaskAPITestServer(t *testing.T, issue func(context.Context, string) (certificate.IssueResult, deployment.Report, error)) (*Server, *repository.SQLTaskRepository, *repository.SQLAuditRepository, func()) {
t.Helper()
cfg := config.Default(t.TempDir())
cfg := readyTestConfig(t.TempDir())
db, err := database.Open(context.Background(), cfg.Database)
if err != nil {
t.Fatalf("打开测试数据库失败:%v", err)
@ -58,7 +57,7 @@ func TestTaskAPIRecordsSuccessfulIssueAndAudit(t *testing.T) {
defer closeDB()
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
if response.Code != http.StatusOK {
t.Fatalf("成功任务响应错误:%d %s", response.Code, response.Body.String())
}
@ -76,25 +75,25 @@ func TestTaskAPIRecordsSuccessfulIssueAndAudit(t *testing.T) {
}
list := httptest.NewRecorder()
server.Handler().ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/api/v1/tasks?limit=10", nil))
serveAdminHTTP(server, list, httptest.NewRequest(http.MethodGet, "/api/v1/tasks?limit=10", nil))
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), items[0].ID) {
t.Fatalf("任务列表响应错误:%d %s", list.Code, list.Body.String())
}
detail := httptest.NewRecorder()
server.Handler().ServeHTTP(detail, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+items[0].ID, nil))
serveAdminHTTP(server, detail, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+items[0].ID, nil))
if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), "success") {
t.Fatalf("任务详情响应错误:%d %s", detail.Code, detail.Body.String())
}
attemptList := httptest.NewRecorder()
server.Handler().ServeHTTP(attemptList, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+items[0].ID+"/attempts", nil))
serveAdminHTTP(server, attemptList, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+items[0].ID+"/attempts", nil))
if attemptList.Code != http.StatusOK || !strings.Contains(attemptList.Body.String(), "certificate_issue") {
t.Fatalf("任务尝试接口响应错误:%d %s", attemptList.Code, attemptList.Body.String())
}
logList := httptest.NewRecorder()
server.Handler().ServeHTTP(logList, httptest.NewRequest(http.MethodGet, "/api/v1/audit-logs?limit=10", nil))
serveAdminHTTP(server, logList, httptest.NewRequest(http.MethodGet, "/api/v1/audit-logs?limit=10", nil))
if logList.Code != http.StatusOK || !strings.Contains(logList.Body.String(), "certificate_issue") {
t.Fatalf("审计日志接口响应错误:%d %s", logList.Code, logList.Body.String())
}
@ -107,7 +106,7 @@ func TestTaskAPIReturnsFailureAndKeepsTaskTrace(t *testing.T) {
defer closeDB()
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
if response.Code != http.StatusBadGateway || !strings.Contains(response.Body.String(), "模拟证书申请失败") {
t.Fatalf("失败任务响应错误:%d %s", response.Code, response.Body.String())
}
@ -145,7 +144,7 @@ func TestTaskAPIRejectsUnknownTask(t *testing.T) {
server, _, _, closeDB := newTaskAPITestServer(t, nil)
defer closeDB()
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/unknown", nil))
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, "/api/v1/tasks/unknown", nil))
if response.Code != http.StatusNotFound {
t.Fatalf("未知任务应返回 404:%d %s", response.Code, response.Body.String())
}

View File

@ -0,0 +1,20 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func readyTestConfig(directory string) config.Config {
cfg := config.Default(directory)
cfg.SetupReady = true
return cfg
}
func serveAdminHTTP(server *Server, recorder *httptest.ResponseRecorder, request *http.Request) {
request.AddCookie(httpUserSessionCookie(server, "test-admin", repository.UserRoleAdmin))
server.Handler().ServeHTTP(recorder, request)
}