Smail/backend/controllers/config_error_test.go
smail e97a8caade
Some checks are pending
Security Checks / security (push) Waiting to run
补充数据库迁移建库权限预检并准确提示权限不足
2026-10-04 03:34:42 +08:00

214 lines
7.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

package controllers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/dbmigration"
)
func TestConfigSSLErrorUsesSafeSpecificMessage(t *testing.T) {
for _, failure := range []error{config.ErrSSLFileUnavailable, config.ErrSSLCertificateMismatch, config.ErrSSLCertificateExpired, config.ErrSSLCertificateDomain, config.ErrInvalidSSLReference} {
code, status, message, _ := configErrorDetails(fmt.Errorf("%w: /private/internal-path", failure))
if code != "SSL_INVALID" || status != http.StatusUnprocessableEntity || message == "" || strings.Contains(message, "/private/") || strings.Contains(message, "配置校验未通过") {
t.Fatalf("证书错误未正确脱敏或区分: %s %d %s", code, status, message)
}
}
}
func TestWriteConfigErrorKeepsStableAuthorizationCode(t *testing.T) {
recorder := httptest.NewRecorder()
writeConfigError(recorder, &access.Denial{Code: access.CodeRecoveryCodeRequired})
var payload struct {
ErrorNo int `json:"errorNo"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to decode authorization error response: %v", err)
}
if payload.ErrorNo != response.NoAccessPrivileges {
t.Fatalf("unexpected authorization error number: %d", payload.ErrorNo)
}
if payload.Data["code"] != string(access.CodeRecoveryCodeRequired) {
t.Fatalf("stable authorization code was not exposed: %#v", payload.Data)
}
}
func TestWriteConfigErrorDoesNotExposeInternalErrorText(t *testing.T) {
recorder := httptest.NewRecorder()
writeConfigError(recorder, &access.Denial{Code: access.CodeRecentAuthRequired})
body := strings.ToLower(recorder.Body.String())
for _, forbidden := range []string{"password", "secret", "token", "nonce", "ticket", "challenge"} {
if strings.Contains(body, forbidden) {
t.Fatalf("authorization response contains sensitive response text: %s", body)
}
}
}
func TestEnsureConfigRequestIDPreservesSafeHeader(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest("GET", "/api/v1/config/status", nil)
request.Header.Set("X-Request-ID", "ui-request-42")
if got := EnsureConfigRequestID(recorder, request); got != "ui-request-42" {
t.Fatalf("safe request id was not preserved: %q", got)
}
if recorder.Header().Get("X-Request-ID") != "ui-request-42" {
t.Fatalf("response request id header was not preserved: %q", recorder.Header().Get("X-Request-ID"))
}
}
func TestEnsureConfigRequestIDReplacesUnsafeHeader(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest("GET", "/api/v1/config/status", nil)
request.Header.Set("X-Request-ID", "../../secret")
requestID := EnsureConfigRequestID(recorder, request)
if requestID == "../../secret" || !requestIDPattern.MatchString(requestID) {
t.Fatalf("unsafe request id was not replaced: %q", requestID)
}
}
func TestWriteConfigErrorUsesStableCodeAndHTTPStatus(t *testing.T) {
recorder := httptest.NewRecorder()
recorder.Header().Set("X-Request-ID", "request-99")
writeConfigError(recorder, config.ErrGenerationConflict)
if recorder.Code != 409 {
t.Fatalf("unexpected HTTP status: %d", recorder.Code)
}
var payload struct {
ErrorMsg string `json:"errorMsg"`
Data response.ConfigErrorData `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to decode configuration error response: %v", err)
}
if payload.Data.Code != "CONFIG_GENERATION_CONFLICT" {
t.Fatalf("unexpected stable error code: %q", payload.Data.Code)
}
if payload.Data.RequestID != "request-99" {
t.Fatalf("unexpected request id: %q", payload.Data.RequestID)
}
if payload.ErrorMsg == "" || strings.Contains(payload.ErrorMsg, "generation conflict") {
t.Fatalf("configuration error message is not a Chinese public message: %q", payload.ErrorMsg)
}
}
func TestConfigErrorDetailsMapsDatabaseMigrationFailures(t *testing.T) {
tests := []struct {
name string
err error
code string
status int
message string
}{
{
name: "invalid target",
err: config.ErrDatabaseMigrationTarget,
code: "DATABASE_MIGRATION_VALIDATION_FAILED",
status: 422,
message: "数据库迁移参数校验未通过",
},
{
name: "structured target exists",
err: dbmigration.ErrSQLTargetExists,
code: "DATABASE_MIGRATION_TARGET_EXISTS",
status: 409,
message: "目标数据库已存在,请更换数据库名称",
},
{
name: "copier unavailable",
err: dbmigration.ErrSQLCopyUnavailable,
code: "DATABASE_MIGRATION_COPIER_UNAVAILABLE",
status: 503,
message: "数据库迁移复制器暂不可用,请先完成服务部署",
},
{
name: "connection unavailable",
err: dbmigration.ErrSQLConnectionUnavailable,
code: "DATABASE_MIGRATION_CONNECTION_UNAVAILABLE",
status: 503,
message: "数据库连接暂不可用,请检查数据库配置和服务状态",
},
{
name: "target creation permission denied",
err: dbmigration.ErrSQLPermissionDenied,
code: "DATABASE_MIGRATION_PERMISSION_DENIED",
status: 422,
message: "目标数据库账号权限不足,请联系数据库管理员授予建库权限后重新预览;PostgreSQL 需要 CREATEDB 权限,迁移完成后可撤销",
},
{
name: "unsupported object",
err: dbmigration.ErrSQLUnsupportedObject,
code: "DATABASE_MIGRATION_UNSUPPORTED_OBJECT",
status: 422,
message: "源数据库包含暂不支持迁移的对象,请先处理后重试",
},
{
name: "data verification failed",
err: dbmigration.ErrSQLDataVerification,
code: "SQL_DATA_VERIFICATION_FAILED",
status: 409,
message: "数据库迁移数据校验未通过,源数据库可能在迁移期间发生变化",
},
{
name: "source busy",
err: dbmigration.ErrSQLSourceBusy,
code: "SQL_SOURCE_BUSY",
status: 409,
message: "源数据库存在活动连接,请停止相关连接后重试迁移",
},
{
name: "switch outcome unknown",
err: dbmigration.ErrSwitchOutcomeUnknown,
code: "SWITCH_OUTCOME_UNKNOWN",
status: 503,
message: "数据库迁移配置切换结果暂不确定,请先确认当前配置和目标数据库状态",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
code, status, message, fields := configErrorDetails(test.err)
if code != test.code || status != test.status || message != test.message || fields != nil {
t.Fatalf("config error details = (%q, %d, %q, %#v), want (%q, %d, %q, nil)", code, status, message, fields, test.code, test.status, test.message)
}
})
}
}
func TestConfigErrorDetailsMapsRuntimeSnapshotUnavailable(t *testing.T) {
code, status, message, fields := configErrorDetails(config.ErrRuntimeSnapshotUnavailable)
if code != "CONFIG_RUNTIME_SNAPSHOT_UNAVAILABLE" {
t.Fatalf("error code = %q", code)
}
if status != http.StatusServiceUnavailable {
t.Fatalf("status = %d", status)
}
if message != "配置已保存,但运行时配置尚未切换,请重试应用" {
t.Fatalf("message = %q", message)
}
if fields != nil {
t.Fatalf("fields = %+v, want nil", fields)
}
}
func TestConfigErrorDetailsMapsRollbackInProgress(t *testing.T) {
code, status, message, _ := configErrorDetails(config.ErrRollbackInProgress)
if code != "CONFIG_ROLLBACK_IN_PROGRESS" || status != http.StatusConflict {
t.Fatalf("rollback in progress mapping = %q/%d, want conflict", code, status)
}
if message == "" || strings.Contains(message, "rollback in progress") {
t.Fatalf("rollback in progress message is not a Chinese public message: %q", message)
}
}