security_audit_errors_test.go 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. package handler
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  9. "github.com/Wei-Shaw/sub2api/internal/securityaudit"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. )
  13. func promptGuardDecision(kind securityaudit.DecisionKind) *securityaudit.Decision {
  14. decision := &securityaudit.Decision{Kind: kind, AllowNextStage: false}
  15. switch kind {
  16. case securityaudit.DecisionBlock:
  17. decision.HTTPStatus = http.StatusForbidden
  18. decision.ErrorCode = securityaudit.ErrorCodeBlocked
  19. decision.ClientMessage = "提示词安全审计拒绝了该请求,请调整输入后重试"
  20. case securityaudit.DecisionInvalid:
  21. decision.HTTPStatus = http.StatusServiceUnavailable
  22. decision.ErrorCode = securityaudit.ErrorCodeInvalidResponse
  23. decision.ClientMessage = "提示词安全审计暂时不可用,请稍后重试"
  24. default:
  25. decision.HTTPStatus = http.StatusServiceUnavailable
  26. decision.ErrorCode = securityaudit.ErrorCodeUnavailable
  27. decision.ClientMessage = "提示词安全审计暂时不可用,请稍后重试"
  28. }
  29. return decision
  30. }
  31. func securityAuditErrorTestContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
  32. t.Helper()
  33. recorder := httptest.NewRecorder()
  34. c, _ := gin.CreateTestContext(recorder)
  35. ctx := context.WithValue(context.Background(), ctxkey.RequestID, "request-error-golden")
  36. c.Request = httptest.NewRequest(http.MethodPost, "/v1/test", nil).WithContext(ctx)
  37. return c, recorder
  38. }
  39. func decodeErrorJSON(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
  40. t.Helper()
  41. var payload map[string]any
  42. require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
  43. return payload
  44. }
  45. func requireObject(t *testing.T, value any) map[string]any {
  46. t.Helper()
  47. object, ok := value.(map[string]any)
  48. require.True(t, ok)
  49. return object
  50. }
  51. func requireArray(t *testing.T, value any) []any {
  52. t.Helper()
  53. array, ok := value.([]any)
  54. require.True(t, ok)
  55. return array
  56. }
  57. func TestPromptGuardOpenAIAndClaudeErrorEnvelopesGolden(t *testing.T) {
  58. gin.SetMode(gin.TestMode)
  59. for _, kind := range []securityaudit.DecisionKind{securityaudit.DecisionBlock, securityaudit.DecisionUnavailable, securityaudit.DecisionInvalid} {
  60. decision := promptGuardDecision(kind)
  61. t.Run("openai_"+string(kind), func(t *testing.T) {
  62. c, recorder := securityAuditErrorTestContext(t)
  63. (&OpenAIGatewayHandler{}).openAISecurityAuditError(c, decision)
  64. require.Equal(t, decision.HTTPStatus, recorder.Code)
  65. payload := decodeErrorJSON(t, recorder)
  66. errorObject := requireObject(t, payload["error"])
  67. require.Equal(t, decision.ErrorCode, errorObject["code"])
  68. if kind == securityaudit.DecisionBlock {
  69. require.Equal(t, "permission_error", errorObject["type"])
  70. } else {
  71. require.Equal(t, "api_error", errorObject["type"])
  72. }
  73. require.NotContains(t, recorder.Body.String(), "raw prompt")
  74. require.NotContains(t, recorder.Body.String(), "guard-one")
  75. })
  76. t.Run("responses_"+string(kind), func(t *testing.T) {
  77. c, recorder := securityAuditErrorTestContext(t)
  78. (&GatewayHandler{}).responsesSecurityAuditError(c, decision)
  79. require.Equal(t, decision.HTTPStatus, recorder.Code)
  80. errorObject := requireObject(t, decodeErrorJSON(t, recorder)["error"])
  81. require.Equal(t, decision.ErrorCode, errorObject["code"])
  82. require.Equal(t, "api_error", errorObject["type"])
  83. })
  84. t.Run("claude_"+string(kind), func(t *testing.T) {
  85. c, recorder := securityAuditErrorTestContext(t)
  86. (&GatewayHandler{}).anthropicSecurityAuditError(c, decision)
  87. require.Equal(t, decision.HTTPStatus, recorder.Code)
  88. payload := decodeErrorJSON(t, recorder)
  89. require.Equal(t, "error", payload["type"])
  90. errorObject := requireObject(t, payload["error"])
  91. require.Equal(t, decision.ErrorCode, errorObject["code"])
  92. if kind == securityaudit.DecisionBlock {
  93. require.Equal(t, "permission_error", errorObject["type"])
  94. } else {
  95. require.Equal(t, "api_error", errorObject["type"])
  96. }
  97. })
  98. }
  99. }
  100. func TestPromptGuardGeminiErrorEnvelopeGolden(t *testing.T) {
  101. gin.SetMode(gin.TestMode)
  102. for _, kind := range []securityaudit.DecisionKind{securityaudit.DecisionBlock, securityaudit.DecisionUnavailable, securityaudit.DecisionInvalid} {
  103. decision := promptGuardDecision(kind)
  104. c, recorder := securityAuditErrorTestContext(t)
  105. googleSecurityAuditError(c, decision)
  106. require.Equal(t, decision.HTTPStatus, recorder.Code)
  107. payload := decodeErrorJSON(t, recorder)
  108. errorObject := requireObject(t, payload["error"])
  109. require.Equal(t, float64(decision.HTTPStatus), errorObject["code"], "Gemini code must remain numeric")
  110. if decision.HTTPStatus == http.StatusForbidden {
  111. require.Equal(t, "PERMISSION_DENIED", errorObject["status"])
  112. } else {
  113. require.Equal(t, "UNAVAILABLE", errorObject["status"])
  114. }
  115. details := requireArray(t, errorObject["details"])
  116. require.Len(t, details, 1)
  117. errorInfo := requireObject(t, details[0])
  118. require.Equal(t, "type.googleapis.com/google.rpc.ErrorInfo", errorInfo["@type"])
  119. require.Equal(t, decision.ErrorCode, errorInfo["reason"])
  120. require.Equal(t, "sub2api.securityaudit", errorInfo["domain"])
  121. metadata := requireObject(t, errorInfo["metadata"])
  122. require.Equal(t, map[string]any{"request_id": "request-error-golden"}, metadata)
  123. }
  124. }
  125. func TestPromptGuardWebSocketCloseMappingGolden(t *testing.T) {
  126. require.Equal(t, int64(4403), int64(securityAuditWSCloseStatus(promptGuardDecision(securityaudit.DecisionBlock))))
  127. require.Equal(t, securityaudit.ErrorCodeBlocked, securityAuditWSCloseReason(promptGuardDecision(securityaudit.DecisionBlock)))
  128. require.Equal(t, int64(1013), int64(securityAuditWSCloseStatus(promptGuardDecision(securityaudit.DecisionUnavailable))))
  129. require.Equal(t, securityaudit.ErrorCodeUnavailable, securityAuditWSCloseReason(promptGuardDecision(securityaudit.DecisionUnavailable)))
  130. require.Equal(t, int64(1013), int64(securityAuditWSCloseStatus(promptGuardDecision(securityaudit.DecisionInvalid))))
  131. require.Equal(t, securityaudit.ErrorCodeInvalidResponse, securityAuditWSCloseReason(promptGuardDecision(securityaudit.DecisionInvalid)))
  132. }
  133. func TestLegacyModerationErrorKeepsExistingClientPriority(t *testing.T) {
  134. legacy := &securityaudit.Decision{
  135. Kind: securityaudit.DecisionBlock, HTTPStatus: http.StatusForbidden,
  136. ErrorCode: "content_policy_violation", ClientMessage: "legacy exact message",
  137. Legacy: &securityaudit.LegacyDecision{Blocked: true, StatusCode: http.StatusForbidden, ErrorCode: "content_policy_violation", Message: "legacy exact message"},
  138. Prompt: &securityaudit.PromptDecision{Kind: securityaudit.DecisionBlock, ErrorCode: securityaudit.ErrorCodeBlocked},
  139. }
  140. c, recorder := securityAuditErrorTestContext(t)
  141. (&GatewayHandler{}).openAISecurityAuditError(c, legacy)
  142. require.Equal(t, http.StatusForbidden, recorder.Code)
  143. require.Contains(t, recorder.Body.String(), "legacy exact message")
  144. require.Contains(t, recorder.Body.String(), "content_policy_violation")
  145. require.NotContains(t, recorder.Body.String(), securityaudit.ErrorCodeBlocked)
  146. }