// Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package errors import ( "encoding/json" stderrors "errors" "net/url" "strings" "testing" ) // --------------------------------------------------------------------------- // PATError basic behaviour // --------------------------------------------------------------------------- func TestPATError_Implements(t *testing.T) { t.Parallel() raw := `{"success":false,"code":"PAT_NO_PERMISSION"}` pe := &PATError{RawJSON: raw} if pe.Error() != raw { t.Errorf("Error() = %q, want %q", pe.Error(), raw) } if pe.ExitCode() != ExitCodePermission { t.Errorf("ExitCode() = %d, want %d", pe.ExitCode(), ExitCodePermission) } if pe.RawStderr() != raw { t.Errorf("RawStderr() = %q, want %q", pe.RawStderr(), raw) } } func TestIsPATError_True(t *testing.T) { t.Parallel() err := &PATError{RawJSON: "{}"} if !IsPATError(err) { t.Fatal("expected IsPATError to return true for *PATError") } } func TestIsPATError_False(t *testing.T) { t.Parallel() err := stderrors.New("some other error") if IsPATError(err) { t.Fatal("expected IsPATError to return false for non-PATError") } } // --------------------------------------------------------------------------- // IsPATNoPermissionCode // --------------------------------------------------------------------------- func TestIsPATNoPermissionCode(t *testing.T) { t.Parallel() cases := []struct { code string want bool }{ {"PAT_NO_PERMISSION", true}, {"PAT_LOW_RISK_NO_PERMISSION", true}, {"PAT_MEDIUM_RISK_NO_PERMISSION", true}, {"PAT_HIGH_RISK_NO_PERMISSION", true}, {"PAT_ORG_POLICY_DENIED", true}, {"AGENT_CODE_NOT_EXISTS", false}, {"UNKNOWN_CODE", false}, {"", false}, } for _, tc := range cases { if got := IsPATNoPermissionCode(tc.code); got != tc.want { t.Errorf("IsPATNoPermissionCode(%q) = %v, want %v", tc.code, got, tc.want) } } } // --------------------------------------------------------------------------- // getDWSGatewayErrorCode // --------------------------------------------------------------------------- func TestGetDWSGatewayErrorCode_ErrorCode(t *testing.T) { t.Parallel() body := map[string]any{"errorCode": "DWS_SERVICE_UNAUTHORIZED"} code, ok := getDWSGatewayErrorCode(body) if !ok || code != "DWS_SERVICE_UNAUTHORIZED" { t.Errorf("got (%q, %v), want (DWS_SERVICE_UNAUTHORIZED, true)", code, ok) } } func TestGetDWSGatewayErrorCode_ErrorCodeUnderscore(t *testing.T) { t.Parallel() body := map[string]any{"error_code": "DWS_AUTH_SERVICE_FAILED"} code, ok := getDWSGatewayErrorCode(body) if !ok || code != "DWS_AUTH_SERVICE_FAILED" { t.Errorf("got (%q, %v), want (DWS_AUTH_SERVICE_FAILED, true)", code, ok) } } func TestGetDWSGatewayErrorCode_Unknown(t *testing.T) { t.Parallel() body := map[string]any{"errorCode": "SOME_OTHER_ERROR"} _, ok := getDWSGatewayErrorCode(body) if ok { t.Fatal("expected ok=false for unknown error code") } } func TestGetDWSGatewayErrorCode_Empty(t *testing.T) { t.Parallel() body := map[string]any{} _, ok := getDWSGatewayErrorCode(body) if ok { t.Fatal("expected ok=false for empty body") } } // --------------------------------------------------------------------------- // isNotLoggedInError // --------------------------------------------------------------------------- func TestIsNotLoggedInError_True(t *testing.T) { t.Parallel() body := map[string]any{"error": "Missing service_id or access_key in request headers"} if !isNotLoggedInError(body) { t.Fatal("expected true for Missing service_id message") } } func TestIsNotLoggedInError_False(t *testing.T) { t.Parallel() body := map[string]any{"error": "something else happened"} if isNotLoggedInError(body) { t.Fatal("expected false for unrelated error message") } } func TestIsNotLoggedInError_NoErrorField(t *testing.T) { t.Parallel() body := map[string]any{"message": "Missing service_id or access_key"} if !isNotLoggedInError(body) { t.Fatal("expected true when equivalent auth message is present in message") } } func TestGetDWSGatewayErrorCode_CodeField(t *testing.T) { t.Parallel() body := map[string]any{"code": "DWS_SERVICE_UNAUTHORIZED"} code, ok := getDWSGatewayErrorCode(body) if !ok || code != "DWS_SERVICE_UNAUTHORIZED" { t.Fatalf("getDWSGatewayErrorCode() = (%q, %t), want DWS_SERVICE_UNAUTHORIZED, true", code, ok) } } // --------------------------------------------------------------------------- // isBusinessError // --------------------------------------------------------------------------- func TestIsBusinessError_ErrorField(t *testing.T) { t.Parallel() body := map[string]any{"error": "some error message"} if !isBusinessError(body) { t.Fatal("expected true when 'error' field is present") } } func TestIsBusinessError_SuccessBoolFalse(t *testing.T) { t.Parallel() body := map[string]any{"success": false} if !isBusinessError(body) { t.Fatal("expected true when success=false (bool)") } } func TestIsBusinessError_SuccessStringFalse(t *testing.T) { t.Parallel() body := map[string]any{"success": "False"} if !isBusinessError(body) { t.Fatal("expected true when success=\"False\" (string)") } } func TestIsBusinessError_SuccessTrue(t *testing.T) { t.Parallel() body := map[string]any{"success": true, "data": "ok"} if isBusinessError(body) { t.Fatal("expected false when success=true") } } func TestIsBusinessError_EmptyBody(t *testing.T) { t.Parallel() body := map[string]any{"data": "hello"} if isBusinessError(body) { t.Fatal("expected false for body without error indicators") } } // --------------------------------------------------------------------------- // ClassifyToolResultContent // --------------------------------------------------------------------------- func TestClassifyToolResultContent_GatewayAuth(t *testing.T) { t.Parallel() content := map[string]any{"errorCode": "DWS_SERVICE_UNAUTHORIZED", "message": "expired"} err := ClassifyToolResultContent(content) if err == nil { t.Fatal("expected non-nil error for gateway auth") } var typed *Error if !stderrors.As(err, &typed) { t.Fatalf("expected *Error, got %T", err) } if typed.Category != CategoryAuth { t.Errorf("Category = %v, want %v", typed.Category, CategoryAuth) } if typed.Reason != "gateway_auth_expired" { t.Errorf("Reason = %q, want gateway_auth_expired", typed.Reason) } } func TestClassifyToolResultContent_PATPermission(t *testing.T) { t.Parallel() content := map[string]any{ "code": "PAT_NO_PERMISSION", "data": map[string]any{"desc": "需要授权"}, } err := ClassifyToolResultContent(content) if err == nil { t.Fatal("expected non-nil error for PAT permission") } var patErr *PATError if !stderrors.As(err, &patErr) { t.Fatalf("expected *PATError, got %T", err) } if !strings.Contains(patErr.RawJSON, "PAT_NO_PERMISSION") { t.Errorf("RawJSON should contain PAT_NO_PERMISSION, got: %s", patErr.RawJSON) } } func TestClassifyToolResultContent_PATPermissionLegacyErrorCode(t *testing.T) { t.Parallel() content := map[string]any{ "error_code": "PAT_LOW_RISK_NO_PERMISSION", "data": map[string]any{"desc": "需要授权"}, } err := ClassifyToolResultContent(content) if err == nil { t.Fatal("expected non-nil error for legacy error_code PAT permission") } var patErr *PATError if !stderrors.As(err, &patErr) { t.Fatalf("expected *PATError, got %T", err) } if !strings.Contains(patErr.RawJSON, "PAT_LOW_RISK_NO_PERMISSION") { t.Errorf("RawJSON should contain PAT_LOW_RISK_NO_PERMISSION, got: %s", patErr.RawJSON) } } func TestClassifyToolResultContent_PATAuthRequired(t *testing.T) { t.Parallel() content := map[string]any{ "errorCode": "AGENT_CODE_NOT_EXISTS", "data": map[string]any{"agentCode": "agt-missing"}, } err := ClassifyToolResultContent(content) if err == nil { t.Fatal("expected non-nil error for PAT auth-required selector") } var patErr *PATError if !stderrors.As(err, &patErr) { t.Fatalf("expected *PATError, got %T", err) } if !strings.Contains(patErr.RawJSON, "AGENT_CODE_NOT_EXISTS") { t.Errorf("RawJSON should contain AGENT_CODE_NOT_EXISTS, got: %s", patErr.RawJSON) } } func TestCrossPlatformCoverageClassifyToolResultContentPATOrgPolicyDenied(t *testing.T) { t.Parallel() content := map[string]any{ "success": false, "code": "PAT_ORG_POLICY_DENIED", "message": "business error: code PAT_ORG_POLICY_DENIED", "data": map[string]any{ "policyDesc": "组织策略禁止访问该开源数据权限", "scope": "contact.user.read", }, } err := ClassifyToolResultContent(content) if err == nil { t.Fatal("expected non-nil error for PAT org policy denial") } var patErr *PATError if !stderrors.As(err, &patErr) { t.Fatalf("expected *PATError, got %T", err) } if patErr.ExitCode() != ExitCodePermission { t.Fatalf("ExitCode() = %d, want %d", patErr.ExitCode(), ExitCodePermission) } var parsed map[string]any if err := json.Unmarshal([]byte(patErr.RawJSON), &parsed); err != nil { t.Fatalf("RawJSON is not valid JSON: %v; raw=%s", err, patErr.RawJSON) } data, ok := parsed["data"].(map[string]any) if !ok { t.Fatalf("parsed.data missing or wrong type: %#v", parsed["data"]) } if got, _ := parsed["code"].(string); got != "PAT_ORG_POLICY_DENIED" { t.Fatalf("parsed.code = %q, want PAT_ORG_POLICY_DENIED", got) } if got, _ := data["hint"].(string); !strings.Contains(got, "组织策略") || !strings.Contains(got, "DWS/PAT") { t.Fatalf("data.hint = %q, want explicit org policy hint", got) } if got, _ := data["scope"].(string); got != "contact.user.read" { t.Fatalf("data.scope = %q, want contact.user.read", got) } if got, _ := data["openBrowser"].(bool); got { t.Fatalf("data.openBrowser = true, want false for terminal org policy denial") } } func TestClassifyToolResultContent_NoError(t *testing.T) { t.Parallel() content := map[string]any{"success": true, "data": "ok"} if err := ClassifyToolResultContent(content); err != nil { t.Fatalf("expected nil error, got %v", err) } } // --------------------------------------------------------------------------- // ClassifyMCPResponseText // --------------------------------------------------------------------------- func TestClassifyMCPResponseText_GatewayAuth(t *testing.T) { t.Parallel() text := `{"errorCode":"DWS_SERVICE_UNAUTHORIZED","message":"token expired"}` err := ClassifyMCPResponseText(text) if err == nil { t.Fatal("expected non-nil error") } var typed *Error if !stderrors.As(err, &typed) { t.Fatalf("expected *Error, got %T", err) } if typed.Reason != "gateway_auth_expired" { t.Errorf("Reason = %q, want gateway_auth_expired", typed.Reason) } } func TestClassifyMCPResponseText_NotLoggedIn(t *testing.T) { t.Parallel() text := `{"error":"Missing service_id or access_key in headers"}` err := ClassifyMCPResponseText(text) if err == nil { t.Fatal("expected non-nil error") } var typed *Error if !stderrors.As(err, &typed) { t.Fatalf("expected *Error, got %T", err) } if typed.Reason != "not_configured" { t.Errorf("Reason = %q, want not_configured", typed.Reason) } } func TestClassifyMCPResponseText_PATPermission(t *testing.T) { t.Parallel() text := `{"code":"PAT_HIGH_RISK_NO_PERMISSION","data":{"desc":"high risk"}}` err := ClassifyMCPResponseText(text) if err == nil { t.Fatal("expected non-nil error") } var patErr *PATError if !stderrors.As(err, &patErr) { t.Fatalf("expected *PATError, got %T", err) } if !strings.Contains(patErr.RawJSON, "PAT_HIGH_RISK_NO_PERMISSION") { t.Errorf("RawJSON should contain code, got: %s", patErr.RawJSON) } } func TestClassifyMCPResponseText_PATPermissionLegacyErrorCode(t *testing.T) { t.Parallel() text := `{"error_code":"PAT_MEDIUM_RISK_NO_PERMISSION","data":{"desc":"legacy"}}` err := ClassifyMCPResponseText(text) if err == nil { t.Fatal("expected non-nil error") } var patErr *PATError if !stderrors.As(err, &patErr) { t.Fatalf("expected *PATError, got %T", err) } if !strings.Contains(patErr.RawJSON, "PAT_MEDIUM_RISK_NO_PERMISSION") { t.Errorf("RawJSON should contain legacy code, got: %s", patErr.RawJSON) } } func TestClassifyMCPResponseText_PATAuthRequired(t *testing.T) { t.Parallel() text := `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED","data":{"missingScope":"mail:send"}}` err := ClassifyMCPResponseText(text) if err == nil { t.Fatal("expected non-nil error") } var patErr *PATError if !stderrors.As(err, &patErr) { t.Fatalf("expected *PATError, got %T", err) } if !strings.Contains(patErr.RawJSON, "PAT_SCOPE_AUTH_REQUIRED") { t.Errorf("RawJSON should contain PAT_SCOPE_AUTH_REQUIRED, got: %s", patErr.RawJSON) } if !strings.Contains(patErr.RawJSON, "missingScope") { t.Errorf("RawJSON should preserve missingScope, got: %s", patErr.RawJSON) } } func TestClassifyMCPResponseText_PATBatchAuthPending(t *testing.T) { t.Parallel() text := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-1","uri":"https://example.test/auth"}}` err := ClassifyMCPResponseText(text) if err == nil { t.Fatal("expected non-nil error") } var patErr *PATError if !stderrors.As(err, &patErr) { t.Fatalf("expected *PATError, got %T", err) } if !strings.Contains(patErr.RawJSON, "PAT_BATCH_AUTH_PENDING") { t.Errorf("RawJSON should contain PAT_BATCH_AUTH_PENDING, got: %s", patErr.RawJSON) } if !strings.Contains(patErr.RawJSON, "flow-1") { t.Errorf("RawJSON should preserve flowId, got: %s", patErr.RawJSON) } } func TestCrossPlatformCoverageClassifyMCPResponseTextPATOrgPolicyDenied(t *testing.T) { t.Parallel() text := `{"success":false,"code":"PAT_ORG_POLICY_DENIED","message":"business error: code PAT_ORG_POLICY_DENIED"}` err := ClassifyMCPResponseText(text) if err == nil { t.Fatal("expected non-nil error") } var patErr *PATError if !stderrors.As(err, &patErr) { t.Fatalf("expected *PATError, got %T", err) } var parsed map[string]any if err := json.Unmarshal([]byte(patErr.RawJSON), &parsed); err != nil { t.Fatalf("RawJSON is not valid JSON: %v; raw=%s", err, patErr.RawJSON) } data, ok := parsed["data"].(map[string]any) if !ok { t.Fatalf("parsed.data missing or wrong type: %#v", parsed["data"]) } if got, _ := data["message"].(string); got != "business error: code PAT_ORG_POLICY_DENIED" { t.Fatalf("data.message = %q, want original server message", got) } if got, _ := data["hint"].(string); !strings.Contains(got, "组织策略") { t.Fatalf("data.hint = %q, want explicit org policy hint", got) } if got, _ := data["openBrowser"].(bool); got { t.Fatalf("data.openBrowser = true, want false for terminal org policy denial") } } func TestClassifyMCPResponseText_BusinessError(t *testing.T) { t.Parallel() text := `{"success":false,"errorMsg":"搜索内容不能为空"}` err := ClassifyMCPResponseText(text) if err == nil { t.Fatal("expected non-nil error") } var typed *Error if !stderrors.As(err, &typed) { t.Fatalf("expected *Error, got %T", err) } if typed.Reason != "business_error" { t.Errorf("Reason = %q, want business_error", typed.Reason) } if !strings.Contains(typed.Hint, "搜索关键词") { t.Errorf("Hint should contain search suggestion, got: %s", typed.Hint) } } func TestClassifyMCPResponseText_InvalidJSON(t *testing.T) { t.Parallel() text := "not json at all" if err := ClassifyMCPResponseText(text); err != nil { t.Fatalf("expected nil for invalid JSON, got %v", err) } } func TestClassifyMCPResponseText_NoError(t *testing.T) { t.Parallel() text := `{"success":true,"data":"hello"}` if err := ClassifyMCPResponseText(text); err != nil { t.Fatalf("expected nil for success response, got %v", err) } } // --------------------------------------------------------------------------- // ClassifyPatAuthCheck // --------------------------------------------------------------------------- func TestClassifyPatAuthCheck_PATNoPermission(t *testing.T) { t.Parallel() content := map[string]any{"code": "PAT_NO_PERMISSION", "data": map[string]any{"flowId": "f1"}} patErr := ClassifyPatAuthCheck(content) if patErr == nil { t.Fatal("expected non-nil *PATError") } if !strings.Contains(patErr.RawJSON, "PAT_NO_PERMISSION") { t.Errorf("RawJSON should contain code, got: %s", patErr.RawJSON) } } func TestClassifyPatAuthCheck_LegacyErrorCode(t *testing.T) { t.Parallel() content := map[string]any{"error_code": "PAT_HIGH_RISK_NO_PERMISSION", "data": map[string]any{"flowId": "f1"}} patErr := ClassifyPatAuthCheck(content) if patErr == nil { t.Fatal("expected non-nil *PATError for legacy error_code") } if !strings.Contains(patErr.RawJSON, "PAT_HIGH_RISK_NO_PERMISSION") { t.Errorf("RawJSON should contain PAT_HIGH_RISK_NO_PERMISSION, got: %s", patErr.RawJSON) } } func TestClassifyPatAuthCheck_AgentCodeNotExists(t *testing.T) { t.Parallel() content := map[string]any{"errorCode": "AGENT_CODE_NOT_EXISTS", "data": map[string]any{"clientId": "c1"}} patErr := ClassifyPatAuthCheck(content) if patErr == nil { t.Fatal("expected non-nil *PATError for AGENT_CODE_NOT_EXISTS") } if !strings.Contains(patErr.RawJSON, "AGENT_CODE_NOT_EXISTS") { t.Errorf("RawJSON should contain AGENT_CODE_NOT_EXISTS, got: %s", patErr.RawJSON) } } // TestClassifyPatAuthCheck_scope_auth_required pins the PAT_SCOPE_AUTH_REQUIRED // selector (part of the frozen PAT-family enum; see patAuthRequiredCodes in // internal/errors/pat.go) as a PATError with exit=4 so hosts can kick the // `dws auth login --scope ` branch. func TestClassifyPatAuthCheck_scope_auth_required(t *testing.T) { t.Parallel() content := map[string]any{ "success": false, "code": "PAT_SCOPE_AUTH_REQUIRED", "data": map[string]any{"missingScope": "mail:send"}, } patErr := ClassifyPatAuthCheck(content) if patErr == nil { t.Fatal("expected non-nil *PATError for PAT_SCOPE_AUTH_REQUIRED") } // Error value MUST satisfy the ExitCoder contract (exit=4) so the // process exits with the PAT Frozen code regardless of wrapping. var ec interface{ ExitCode() int } = patErr if ec.ExitCode() != ExitCodePermission { t.Errorf("ExitCode() = %d, want %d", ec.ExitCode(), ExitCodePermission) } // Host-visible RawStderr must carry the selector and, crucially, the // missingScope field that drives `dws auth login --scope `. raw := patErr.RawStderr() if !strings.Contains(raw, "PAT_SCOPE_AUTH_REQUIRED") { t.Errorf("RawStderr missing selector, got: %s", raw) } if !strings.Contains(raw, "missingScope") { t.Errorf("RawStderr missing missingScope field, got: %s", raw) } if !strings.Contains(raw, "mail:send") { t.Errorf("RawStderr missing missingScope value, got: %s", raw) } } func TestClassifyPatAuthCheck_NoMatch(t *testing.T) { t.Parallel() content := map[string]any{"code": "SOME_BUSINESS_ERROR", "message": "oops"} if patErr := ClassifyPatAuthCheck(content); patErr != nil { t.Fatalf("expected nil, got %v", patErr) } } func TestClassifyPatAuthCheck_EmptyContent(t *testing.T) { t.Parallel() content := map[string]any{} if patErr := ClassifyPatAuthCheck(content); patErr != nil { t.Fatalf("expected nil for empty content, got %v", patErr) } } // --------------------------------------------------------------------------- // AsPatAuthCheckError // --------------------------------------------------------------------------- func TestAsPatAuthCheckError_Wrapped(t *testing.T) { t.Parallel() inner := &PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`} wrapped := stderrors.Join(stderrors.New("context"), inner) got := AsPatAuthCheckError(wrapped) if got == nil { t.Fatal("expected non-nil *PATError from wrapped error") } if got.RawJSON != inner.RawJSON { t.Errorf("RawJSON = %q, want %q", got.RawJSON, inner.RawJSON) } } func TestAsPatAuthCheckError_NotPAT(t *testing.T) { t.Parallel() err := stderrors.New("just a plain error") if got := AsPatAuthCheckError(err); got != nil { t.Fatalf("expected nil for non-PAT error, got %v", got) } } // --------------------------------------------------------------------------- // cleanPATJSON // --------------------------------------------------------------------------- func TestCleanPATJSON_WithData(t *testing.T) { t.Parallel() body := map[string]any{ "success": false, "code": "PAT_NO_PERMISSION", "data": map[string]any{ "desc": "需要授权", "flowId": "f123", "class": "com.foo.Bar", }, } result := cleanPATJSON(body, "PAT_NO_PERMISSION") if !strings.Contains(result, "PAT_NO_PERMISSION") { t.Errorf("expected code in output, got: %s", result) } if !strings.Contains(result, "flowId") { t.Errorf("expected flowId in data, got: %s", result) } if strings.Contains(result, "class") { t.Errorf("expected class field to be stripped, got: %s", result) } if !strings.Contains(result, `"openBrowser":true`) { t.Errorf("expected openBrowser default in output, got: %s", result) } } func TestCleanPATJSON_WithoutData(t *testing.T) { t.Parallel() body := map[string]any{ "success": false, "code": "PAT_NO_PERMISSION", "message": "no permission", "extra": "value", } result := cleanPATJSON(body, "PAT_NO_PERMISSION") if !strings.Contains(result, "extra") { t.Errorf("expected extra field in fallback data, got: %s", result) } // Top-level stripped fields should not appear if strings.Contains(result, `"message"`) { t.Errorf("expected message to be stripped from top level, got: %s", result) } } // TestCleanPATJSON_InjectsHostControlWhenClawSet verifies the // single-injection invariant: when the bootstrap wires a non-empty // clawType provider, cleanPATJSON MUST emit data.hostControl. func TestCleanPATJSON_InjectsHostControlWhenClawSet(t *testing.T) { // Not parallel: mutates the package-level provider. t.Cleanup(func() { SetHostControlProvider(nil) }) SetHostControlProvider(func() string { return "my-copilot" }) body := map[string]any{ "success": false, "code": "PAT_NO_PERMISSION", "data": map[string]any{ "desc": "需要授权", "flowId": "f-1", "callbacks": []any{"cb1", "cb2"}, }, } raw := cleanPATJSON(body, "PAT_NO_PERMISSION") var parsed map[string]any if err := json.Unmarshal([]byte(raw), &parsed); err != nil { t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, raw) } data, ok := parsed["data"].(map[string]any) if !ok { t.Fatalf("expected data object, got %T", parsed["data"]) } hc, ok := data["hostControl"].(map[string]any) if !ok { t.Fatalf("expected data.hostControl to be a map, got %T\nraw=%s", data["hostControl"], raw) } if got, _ := hc["clawType"].(string); got != "my-copilot" { t.Errorf("hostControl.clawType = %q, want %q", got, "my-copilot") } if got, _ := hc["callbackOwner"].(string); got != "host" { t.Errorf("hostControl.callbackOwner = %q, want %q", got, "host") } if got, _ := hc["mode"].(string); got != "host" { t.Errorf("hostControl.mode = %q, want %q", got, "host") } if _, ok := data["callbacks"]; ok { t.Fatalf("cleanPATJSON should strip callbacks in host-owned mode, got: %v", data["callbacks"]) } } // TestCleanPATJSON_OmitsHostControlByDefault verifies that cleanPATJSON // does NOT include a hostControl block when the provider is unset or // returns empty (default CLI-owned mode). func TestCleanPATJSON_OmitsHostControlByDefault(t *testing.T) { // Not parallel: reads the package-level provider. t.Cleanup(func() { SetHostControlProvider(nil) }) SetHostControlProvider(nil) body := map[string]any{ "success": false, "code": "PAT_NO_PERMISSION", "data": map[string]any{ "desc": "need auth", }, } raw := cleanPATJSON(body, "PAT_NO_PERMISSION") if strings.Contains(raw, `"hostControl"`) { t.Fatalf("cleanPATJSON should omit hostControl in default mode, got: %s", raw) } SetHostControlProvider(func() string { return "" }) raw = cleanPATJSON(body, "PAT_NO_PERMISSION") if strings.Contains(raw, `"hostControl"`) { t.Fatalf("cleanPATJSON should omit hostControl when provider returns empty, got: %s", raw) } } func TestCleanPATJSON_UsesBrowserPolicyProvider(t *testing.T) { t.Cleanup(func() { SetHostControlProvider(nil) SetPATOpenBrowserProvider(nil) }) SetHostControlProvider(nil) SetPATOpenBrowserProvider(func() bool { return false }) body := map[string]any{ "success": false, "code": "PAT_NO_PERMISSION", "data": map[string]any{ "desc": "need auth", }, } raw := cleanPATJSON(body, "PAT_NO_PERMISSION") var parsed map[string]any if err := json.Unmarshal([]byte(raw), &parsed); err != nil { t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, raw) } data, _ := parsed["data"].(map[string]any) if got, ok := data["openBrowser"].(bool); !ok || got { t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"]) } } // TestCleanPATJSON_SingleLineOutput pins down the wire invariant: stderr // JSON MUST be emitted as a single line (no embedded \n, no pretty-print // indentation) so that naïve host parsers reading stderr line-by-line stay // correct. Regression guard against accidental reintroduction of // json.MarshalIndent. func TestCleanPATJSON_SingleLineOutput(t *testing.T) { t.Parallel() body := map[string]any{ "success": false, "code": "PAT_LOW_RISK_NO_PERMISSION", "data": map[string]any{ "requiredScopes": []any{"aitable.record:read"}, "grantOptions": []any{"session", "permanent"}, "displayName": "读取记录", "productName": "AI 表格", }, } raw := cleanPATJSON(body, "PAT_LOW_RISK_NO_PERMISSION") if strings.Contains(raw, "\n") { t.Fatalf("cleanPATJSON output must be single-line, got embedded newline:\n%s", raw) } if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") { t.Fatalf("cleanPATJSON output must not be indented, got leading whitespace: %q", raw) } // Contract: the payload must remain a directly json.Unmarshal-able // object, even after the single-line constraint is enforced. var parsed map[string]any if err := json.Unmarshal([]byte(raw), &parsed); err != nil { t.Fatalf("single-line output must round-trip via json.Unmarshal: %v\nraw=%s", err, raw) } if code, _ := parsed["code"].(string); code != "PAT_LOW_RISK_NO_PERMISSION" { t.Errorf("code = %q, want %q", code, "PAT_LOW_RISK_NO_PERMISSION") } } func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) { t.Parallel() rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9" body := map[string]any{ "success": false, "code": "PAT_MEDIUM_RISK_NO_PERMISSION", "data": map[string]any{ "desc": "在浏览器中打开以下链接进行认证", "flowId": "50dff7654b7444e88ced7489b07cce8d", "uri": rawURI, }, } result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION") if strings.Contains(result, `\u0026`) { t.Fatalf("cleanPATJSON escaped ampersands in URL: %s", result) } if !strings.Contains(result, "&userCode=Q8RY-X6E9") { t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result) } if strings.Contains(result, `\u0026`) { t.Fatalf("cleanPATJSON should keep URL ampersands readable for mobile copy/linkify, got: %s", result) } if !strings.Contains(result, "&userCode=Q8RY-X6E9") { t.Fatalf("cleanPATJSON output missing readable fragment separator, got: %s", result) } var parsed map[string]any if err := json.Unmarshal([]byte(result), &parsed); err != nil { t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result) } data, _ := parsed["data"].(map[string]any) if got, _ := data["uri"].(string); got != rawURI { t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI) } if _, ok := data["authUrl"]; ok { t.Fatalf("data.authUrl should be omitted when data.uri is present") } if _, ok := data["authorizationUrl"]; ok { t.Fatalf("data.authorizationUrl should be omitted when data.uri is present") } } func TestCleanPATJSON_BackfillsSingleURIFromAuthURL(t *testing.T) { t.Parallel() rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D50dff7654b7444e88ced7489b07cce8d%26userCode%3DQ8RY-X6E9#/personalAuthorization?flowId=50dff7654b7444e88ced7489b07cce8d&userCode=Q8RY-X6E9" body := map[string]any{ "success": false, "code": "PAT_BATCH_AUTH_PENDING", "data": map[string]any{ "flowId": "50dff7654b7444e88ced7489b07cce8d", "authUrl": rawURI, }, } result := cleanPATJSON(body, "PAT_BATCH_AUTH_PENDING") var parsed map[string]any if err := json.Unmarshal([]byte(result), &parsed); err != nil { t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result) } data, _ := parsed["data"].(map[string]any) if got, _ := data["uri"].(string); got != rawURI { t.Fatalf("data.uri = %q, want %q", got, rawURI) } if _, ok := data["authUrl"]; ok { t.Fatalf("data.authUrl should be omitted after backfilling data.uri") } if _, ok := data["authorizationUrl"]; ok { t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri") } } func TestPATAuthorizationURL_NormalizesLegacyHashRoute(t *testing.T) { t.Parallel() rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2" want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2#/personalAuthorization?flowId=77108a9d0e6f4b74b769c04eb451e7d9&userCode=WSAX-EEF2" if got := PATAuthorizationURL(rawURI); got != want { t.Fatalf("PATAuthorizationURL() = %q, want %q", got, want) } } func TestPATAuthorizationURL_NormalizesLegacyHashRoutePreservesExtraQuery(t *testing.T) { t.Parallel() rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2%26agentCode%3Dcodex%26scene%3Ddesktop%26redirect%3Dhttps%253A%252F%252Fexample.com%252Fcallback%253Fa%253D1" got := PATAuthorizationURL(rawURI) if got == rawURI { t.Fatal("expected legacy hash route to be normalized") } parsed, err := url.Parse(got) if err != nil { t.Fatalf("parse normalized URL: %v\nurl=%s", err, got) } hash := parsed.Query().Get("hash") if hash == "" { t.Fatalf("expected normalized URL to include hash query, got: %s", got) } if hash != "#"+parsed.Fragment { t.Fatalf("hash query = %q, want fragment route %q", hash, "#"+parsed.Fragment) } rawQuery, ok := strings.CutPrefix(parsed.Fragment, "/personalAuthorization?") if !ok { t.Fatalf("fragment = %q, want personalAuthorization route", parsed.Fragment) } values, err := url.ParseQuery(rawQuery) if err != nil { t.Fatalf("parse normalized route query: %v\nquery=%s", err, rawQuery) } want := map[string]string{ "flowId": "77108a9d0e6f4b74b769c04eb451e7d9", "userCode": "WSAX-EEF2", "agentCode": "codex", "scene": "desktop", "redirect": "https://example.com/callback?a=1", } for key, wantValue := range want { if gotValue := values.Get(key); gotValue != wantValue { t.Fatalf("route query %s = %q, want %q", key, gotValue, wantValue) } } } func TestCleanPATJSON_NormalizesSingleURI(t *testing.T) { t.Parallel() rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN" want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN" body := map[string]any{ "success": false, "code": "PAT_MEDIUM_RISK_NO_PERMISSION", "data": map[string]any{ "desc": "在浏览器中打开以下链接进行认证", "flowId": "56b12fd3201d4efab9a9138672cf4deb", "uri": rawURI, }, } result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION") if strings.Contains(result, `\u0026`) { t.Fatalf("cleanPATJSON escaped ampersands in normalized URL: %s", result) } if !strings.Contains(result, "&userCode=CFTC-27ZN") { t.Fatalf("cleanPATJSON output missing literal ampersand route separator: %s", result) } var parsed map[string]any if err := json.Unmarshal([]byte(result), &parsed); err != nil { t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result) } data, _ := parsed["data"].(map[string]any) if got, _ := data["uri"].(string); got != want { t.Fatalf("data.uri = %q, want normalized %q", got, want) } if _, ok := data["authUrl"]; ok { t.Fatalf("data.authUrl should be omitted after normalizing data.uri") } if _, ok := data["authorizationUrl"]; ok { t.Fatalf("data.authorizationUrl should be omitted after normalizing data.uri") } } // --------------------------------------------------------------------------- // stripClassFields // --------------------------------------------------------------------------- func TestStripClassFields_Map(t *testing.T) { t.Parallel() input := map[string]any{ "name": "test", "class": "com.foo.Bar", "nested": map[string]any{ "value": 42, "class": "com.baz.Qux", }, } result := stripClassFields(input).(map[string]any) if _, ok := result["class"]; ok { t.Error("top-level class should be removed") } nested := result["nested"].(map[string]any) if _, ok := nested["class"]; ok { t.Error("nested class should be removed") } if nested["value"] != 42 { t.Errorf("nested value should be preserved, got %v", nested["value"]) } } func TestStripClassFields_Array(t *testing.T) { t.Parallel() input := []any{ map[string]any{"id": 1, "class": "Foo"}, map[string]any{"id": 2}, } result := stripClassFields(input).([]any) first := result[0].(map[string]any) if _, ok := first["class"]; ok { t.Error("class in array element should be removed") } if first["id"] != 1 { t.Error("other fields in array element should be preserved") } } func TestStripClassFields_Scalar(t *testing.T) { t.Parallel() if stripClassFields("hello") != "hello" { t.Error("scalar string should pass through unchanged") } if stripClassFields(42) != 42 { t.Error("scalar int should pass through unchanged") } } // --------------------------------------------------------------------------- // suggestForBusinessErrorText // --------------------------------------------------------------------------- func TestSuggestForBusinessErrorText(t *testing.T) { t.Parallel() cases := []struct { body map[string]any contains string }{ {map[string]any{"errorMsg": "搜索内容不能为空"}, "搜索关键词"}, {map[string]any{"message": "User has no permission to access this email"}, "邮箱"}, {map[string]any{"error": "频率超限"}, "rate limit"}, {map[string]any{"errorMsg": "参数错误"}, "parameters"}, {map[string]any{"error": "unknown"}, "business error"}, } for _, tc := range cases { hint := suggestForBusinessErrorText(tc.body) if !strings.Contains(strings.ToLower(hint), strings.ToLower(tc.contains)) { t.Errorf("suggestForBusinessErrorText(%v) = %q, want to contain %q", tc.body, hint, tc.contains) } } }