package httpapi import ( "context" "crypto/ed25519" "crypto/rand" "crypto/x509" "encoding/json" "encoding/pem" "net/http" "net/http/httptest" "path/filepath" "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" "git.pcux.cn/xuans/Scert/backend/internal/sshkey" ) func TestSSHKeyAPIHidesPrivateKeyAndBlocksDeletingUsedReference(t *testing.T) { directory := t.TempDir() cfg := config.Default(directory) cfg.Database.DSN = filepath.Join(directory, "data", "ssh-keys.db") db, err := database.Open(context.Background(), cfg.Database) if err != nil { t.Fatalf("打开数据库失败:%v", err) } defer db.Close() if err := database.Migrate(context.Background(), db.DB, "sqlite"); err != nil { t.Fatalf("数据库迁移失败:%v", err) } store, err := secretstore.Open(filepath.Join(directory, "config")) if err != nil { t.Fatalf("打开敏感信息存储失败:%v", err) } serverRepo := repository.NewSQLServerRepository(db.DB) server := New(Dependencies{ Config: cfg, Repository: repository.NewSQLDomainRepository(db.DB), Servers: serverRepo, SSHKeys: sshkey.NewService(store), }) privateKey := testSSHPrivateKey(t) createBody, err := json.Marshal(sshKeyRequest{Name: "生产私钥", PrivateKey: privateKey}) if err != nil { t.Fatalf("编码 SSH 私钥请求失败:%v", err) } 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) if createResponse.Code != http.StatusCreated { t.Fatalf("创建 SSH 私钥失败:%d %s", createResponse.Code, createResponse.Body.String()) } if strings.Contains(createResponse.Body.String(), "PRIVATE KEY") || strings.Contains(createResponse.Body.String(), "private_key") { t.Fatalf("SSH 私钥创建响应泄露了敏感内容:%s", createResponse.Body.String()) } var key sshkey.Metadata if err := json.Unmarshal(createResponse.Body.Bytes(), &key); err != nil || key.ID == "" || key.Reference == "" { t.Fatalf("SSH 私钥创建响应无效:%v %+v", err, key) } listResponse := httptest.NewRecorder() server.Handler().ServeHTTP(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()) } serverBody := `{"name":"生产服务器","address":"192.0.2.60","port":22,"username":"deploy","private_key_ref":"` + key.Reference + `","host_key_fingerprint":"SHA256:test","certificate_path":"/etc/scert/cert.pem","private_key_path":"/etc/scert/key.pem","certificate_chain_path":"/etc/scert/fullchain.pem","web_server":"nginx"}` 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) if serverResponse.Code != http.StatusCreated { t.Fatalf("创建使用 SSH 私钥的服务器失败:%d %s", serverResponse.Code, serverResponse.Body.String()) } var createdServer struct { ID string `json:"id"` } if err := json.Unmarshal(serverResponse.Body.Bytes(), &createdServer); err != nil || createdServer.ID == "" { t.Fatalf("服务器创建响应无效:%v %s", err, serverResponse.Body.String()) } deleteUsedResponse := httptest.NewRecorder() server.Handler().ServeHTTP(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)) 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)) if deleteKeyResponse.Code != http.StatusNoContent { t.Fatalf("删除未使用的 SSH 私钥失败:%d %s", deleteKeyResponse.Code, deleteKeyResponse.Body.String()) } } func TestSSHKeyAPIRejectsInvalidKeyWithoutEchoingIt(t *testing.T) { store, err := secretstore.Open(t.TempDir()) if err != nil { t.Fatalf("打开敏感信息存储失败:%v", err) } server := New(Dependencies{Config: config.Default(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) if response.Code != http.StatusBadRequest || strings.Contains(response.Body.String(), secret) { t.Fatalf("无效 SSH 私钥响应不安全:%d %s", response.Code, response.Body.String()) } } func TestSSHKeyAPIRejectsOversizedRequestBody(t *testing.T) { store, err := secretstore.Open(t.TempDir()) 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)}) 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) if response.Code != http.StatusRequestEntityTooLarge { t.Fatalf("oversized SSH key request should return 413: %d %s", response.Code, response.Body.String()) } } func TestSSHKeyAPIRejectsOversizedPrivateKeyAndPassphrase(t *testing.T) { store, err := secretstore.Open(t.TempDir()) 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)}) requests := []sshKeyRequest{ {Name: "oversized key", PrivateKey: strings.Repeat("a", sshkey.MaxPrivateKeyBytes+1)}, {Name: "oversized passphrase", PrivateKey: testSSHPrivateKey(t), Passphrase: strings.Repeat("p", 4097)}, } for _, input := range requests { body, err := json.Marshal(input) if err != nil { t.Fatalf("failed to encode limit test request: %v", err) } 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) 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()) } } } func testSSHPrivateKey(t *testing.T) string { t.Helper() _, key, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("生成测试 SSH 私钥失败:%v", err) } encoded, err := x509.MarshalPKCS8PrivateKey(key) if err != nil { t.Fatalf("编码测试 SSH 私钥失败:%v", err) } return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded})) }