From fb8e6217b9695c206083126d2ed53eeac6622ec5 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 30 Aug 2026 20:32:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E5=92=8CSSH=E6=8E=A5=E5=8F=A3=E8=AF=B7=E6=B1=82=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/httpapi/certificate_api_test.go | 17 +++-- backend/internal/httpapi/middleware.go | 5 +- backend/internal/httpapi/middleware_test.go | 27 +++++++ backend/internal/httpapi/review_test.go | 32 ++++----- backend/internal/httpapi/server_test.go | 66 ++++++++--------- backend/internal/httpapi/ssh_key.go | 3 +- backend/internal/httpapi/ssh_key_test.go | 72 +++++++++++++++---- backend/internal/httpapi/task_api_test.go | 17 +++-- backend/internal/httpapi/test_helpers_test.go | 20 ++++++ 9 files changed, 175 insertions(+), 84 deletions(-) create mode 100644 backend/internal/httpapi/test_helpers_test.go diff --git a/backend/internal/httpapi/certificate_api_test.go b/backend/internal/httpapi/certificate_api_test.go index 359d8a3..40edfc7 100644 --- a/backend/internal/httpapi/certificate_api_test.go +++ b/backend/internal/httpapi/certificate_api_test.go @@ -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()) } diff --git a/backend/internal/httpapi/middleware.go b/backend/internal/httpapi/middleware.go index 569decf..98c43ed 100644 --- a/backend/internal/httpapi/middleware.go +++ b/backend/internal/httpapi/middleware.go @@ -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) diff --git a/backend/internal/httpapi/middleware_test.go b/backend/internal/httpapi/middleware_test.go index 446bfdc..4e95002 100644 --- a/backend/internal/httpapi/middleware_test.go +++ b/backend/internal/httpapi/middleware_test.go @@ -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()) + } + }) + } +} diff --git a/backend/internal/httpapi/review_test.go b/backend/internal/httpapi/review_test.go index 7cf72d6..026b48b 100644 --- a/backend/internal/httpapi/review_test.go +++ b/backend/internal/httpapi/review_test.go @@ -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()) } diff --git a/backend/internal/httpapi/server_test.go b/backend/internal/httpapi/server_test.go index a341112..3d4f452 100644 --- a/backend/internal/httpapi/server_test.go +++ b/backend/internal/httpapi/server_test.go @@ -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" diff --git a/backend/internal/httpapi/ssh_key.go b/backend/internal/httpapi/ssh_key.go index 2adcdc7..c014b4f 100644 --- a/backend/internal/httpapi/ssh_key.go +++ b/backend/internal/httpapi/ssh_key.go @@ -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) diff --git a/backend/internal/httpapi/ssh_key_test.go b/backend/internal/httpapi/ssh_key_test.go index b0a3177..bc9af90 100644 --- a/backend/internal/httpapi/ssh_key_test.go +++ b/backend/internal/httpapi/ssh_key_test.go @@ -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) +} diff --git a/backend/internal/httpapi/task_api_test.go b/backend/internal/httpapi/task_api_test.go index 4f6b1b0..b3d7693 100644 --- a/backend/internal/httpapi/task_api_test.go +++ b/backend/internal/httpapi/task_api_test.go @@ -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()) } diff --git a/backend/internal/httpapi/test_helpers_test.go b/backend/internal/httpapi/test_helpers_test.go new file mode 100644 index 0000000..8abb669 --- /dev/null +++ b/backend/internal/httpapi/test_helpers_test.go @@ -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) +}