Files
dingtalk-workspace-cli/test/unit/pat_stderr_contract_test.go

299 lines
9.4 KiB
Go

// 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 unit_test
import (
"encoding/json"
"strings"
"testing"
errpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// D5 Smoke A — PAT stderr JSON is a single-line, json.Unmarshal-able
// payload for every code in the frozen enum.
//
// This test is the CI-level guard for the wire invariant ("stderr JSON
// MUST be single-line and directly unmarshal-able"). It sits in the
// public `test/unit` tree — deliberately separate from
// internal/errors/pat_test.go — so the contract stays defended even
// after internal refactors that might move the JSON assembly code to
// another package or collapse helpers into it.
//
// Wire path exercised: ClassifyPatAuthCheck covers BOTH error families
// the SSOT freezes — patNoPermissionCodes (PAT_NO_PERMISSION and the
// three risk-tier variants) and patAuthRequiredCodes
// (PAT_SCOPE_AUTH_REQUIRED, AGENT_CODE_NOT_EXISTS). Using this single
// entry point keeps the test format-agnostic to future code-churn in
// ClassifyMCPResponseText's branching while still covering the full
// SSOT code enum.
//
// What we assert for every case:
// 1. Non-nil *PATError is returned with ExitCode == 4.
// 2. RawJSON has NO '\n' characters (single-line invariant).
// 3. RawJSON is directly json.Unmarshal-able into a map.
// 4. Parsed body has success=false and code matching the fixture.
// 5. Required downstream data shape is present (requiredScopes for
// PAT_*_NO_PERMISSION; missingScope for PAT_SCOPE_AUTH_REQUIRED).
func TestPATStderrJSON_SingleLineUnmarshalable(t *testing.T) {
t.Parallel()
type assertFn func(t *testing.T, parsed map[string]any)
requiredScopesPresent := func(t *testing.T, parsed map[string]any) {
t.Helper()
data, ok := parsed["data"].(map[string]any)
if !ok {
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
}
scopes, ok := data["requiredScopes"].([]any)
if !ok || len(scopes) == 0 {
t.Fatalf("parsed.data.requiredScopes missing or empty: %v", data["requiredScopes"])
}
for i, s := range scopes {
if _, ok := s.(string); !ok {
t.Fatalf("parsed.data.requiredScopes[%d] is not a string: %v", i, s)
}
}
}
missingScopePresent := func(t *testing.T, parsed map[string]any) {
t.Helper()
data, ok := parsed["data"].(map[string]any)
if !ok {
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
}
scope, ok := data["missingScope"].(string)
if !ok || scope == "" {
t.Fatalf("parsed.data.missingScope missing or not a string: %v", data["missingScope"])
}
}
agentCodeFieldPresent := func(t *testing.T, parsed map[string]any) {
t.Helper()
data, ok := parsed["data"].(map[string]any)
if !ok {
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
}
if _, ok := data["agentCode"].(string); !ok {
t.Fatalf("parsed.data.agentCode missing or not a string: %v", data["agentCode"])
}
}
orgPolicyHintPresent := func(t *testing.T, parsed map[string]any) {
t.Helper()
data, ok := parsed["data"].(map[string]any)
if !ok {
t.Fatalf("parsed.data missing or wrong type: %v", parsed["data"])
}
hint, ok := data["hint"].(string)
if !ok || !strings.Contains(hint, "组织策略") || !strings.Contains(hint, "DWS/PAT") {
t.Fatalf("parsed.data.hint missing explicit org policy guidance: %v", data["hint"])
}
if openBrowser, ok := data["openBrowser"].(bool); !ok || openBrowser {
t.Fatalf("parsed.data.openBrowser = %v, want false", data["openBrowser"])
}
}
cases := []struct {
name string
code string
body map[string]any
extract assertFn
}{
{
name: "PAT_NO_PERMISSION generic baseline",
code: "PAT_NO_PERMISSION",
body: map[string]any{
"success": false,
"code": "PAT_NO_PERMISSION",
"data": map[string]any{
"requiredScopes": []any{"aitable.record:read"},
"grantOptions": []any{"session", "permanent"},
"displayName": "阅读多维表记录",
"productName": "AI 表格",
},
},
extract: requiredScopesPresent,
},
{
name: "PAT_LOW_RISK_NO_PERMISSION",
code: "PAT_LOW_RISK_NO_PERMISSION",
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 表格",
},
},
extract: requiredScopesPresent,
},
{
name: "PAT_MEDIUM_RISK_NO_PERMISSION",
code: "PAT_MEDIUM_RISK_NO_PERMISSION",
body: map[string]any{
"success": false,
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
"data": map[string]any{
"requiredScopes": []any{"chat.group:write"},
"grantOptions": []any{"session", "permanent"},
"displayName": "发送群消息",
"productName": "群聊",
},
},
extract: requiredScopesPresent,
},
{
name: "PAT_HIGH_RISK_NO_PERMISSION",
code: "PAT_HIGH_RISK_NO_PERMISSION",
body: map[string]any{
"success": false,
"code": "PAT_HIGH_RISK_NO_PERMISSION",
"data": map[string]any{
"requiredScopes": []any{"finance.invoice:write"},
"grantOptions": []any{"once"},
"displayName": "开具发票",
"productName": "财务",
"authRequestId": "auth-req-xxxx",
},
},
extract: requiredScopesPresent,
},
{
name: "PAT_ORG_POLICY_DENIED",
code: "PAT_ORG_POLICY_DENIED",
body: map[string]any{
"success": false,
"code": "PAT_ORG_POLICY_DENIED",
"data": map[string]any{
"policyDesc": "组织策略禁止访问该开源数据权限",
"scope": "contact.user.read",
},
},
extract: orgPolicyHintPresent,
},
{
name: "PAT_SCOPE_AUTH_REQUIRED",
code: "PAT_SCOPE_AUTH_REQUIRED",
body: map[string]any{
"success": false,
"code": "PAT_SCOPE_AUTH_REQUIRED",
"data": map[string]any{
"missingScope": "Contact.User.Read",
"hint": "run `dws auth login --scope Contact.User.Read`",
},
},
extract: missingScopePresent,
},
{
name: "AGENT_CODE_NOT_EXISTS",
code: "AGENT_CODE_NOT_EXISTS",
body: map[string]any{
"success": false,
"code": "AGENT_CODE_NOT_EXISTS",
"data": map[string]any{
"agentCode": "agt-unknown",
"hint": "请检查 DINGTALK_DWS_AGENTCODE / agent 注册",
},
},
extract: agentCodeFieldPresent,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
patErr := errpkg.ClassifyPatAuthCheck(tc.body)
if patErr == nil {
t.Fatalf("ClassifyPatAuthCheck(%s) returned nil; expected *PATError", tc.code)
}
if patErr.ExitCode() != errpkg.ExitCodePermission {
t.Fatalf("ExitCode() = %d, want %d (PAT exit_code MUST be 4)",
patErr.ExitCode(), errpkg.ExitCodePermission)
}
raw := patErr.RawStderr()
if raw == "" {
t.Fatalf("RawStderr() is empty; expected single-line JSON")
}
if strings.ContainsAny(raw, "\n\r") {
t.Fatalf("RawStderr() MUST be single-line (no \\n or \\r); got:\n%q", raw)
}
if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
t.Fatalf("RawStderr() MUST not have leading whitespace; got: %q", raw)
}
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
t.Fatalf("json.Unmarshal(RawStderr()) error = %v; raw=%q", err, raw)
}
if success, ok := parsed["success"].(bool); !ok || success {
t.Fatalf("parsed.success = %v, want false", parsed["success"])
}
gotCode, ok := parsed["code"].(string)
if !ok {
t.Fatalf("parsed.code missing or not a string: %v", parsed["code"])
}
if gotCode != tc.code {
t.Fatalf("parsed.code = %q, want %q", gotCode, tc.code)
}
tc.extract(t, parsed)
})
}
}
// TestPATStderrJSON_CodeEnumFrozen pins the exact code enum frozen for
// PAT-family errors (see patNoPermissionCodes / patAuthRequiredCodes in
// internal/errors/pat.go). If a future refactor accidentally renames a
// code (e.g. drops the risk-tier prefix) the classifier will stop
// producing a *PATError and this table-driven loop will fail with a
// clear message — preventing a silent break of the host integration
// contract.
func TestPATStderrJSON_CodeEnumFrozen(t *testing.T) {
t.Parallel()
frozen := []string{
"PAT_NO_PERMISSION",
"PAT_LOW_RISK_NO_PERMISSION",
"PAT_MEDIUM_RISK_NO_PERMISSION",
"PAT_HIGH_RISK_NO_PERMISSION",
"PAT_ORG_POLICY_DENIED",
"PAT_SCOPE_AUTH_REQUIRED",
"PAT_BATCH_AUTH_PENDING",
"AGENT_CODE_NOT_EXISTS",
}
for _, code := range frozen {
code := code
t.Run(code, func(t *testing.T) {
t.Parallel()
body := map[string]any{
"success": false,
"code": code,
"data": map[string]any{"requiredScopes": []any{"stub.entity:read"}},
}
if patErr := errpkg.ClassifyPatAuthCheck(body); patErr == nil {
t.Fatalf("ClassifyPatAuthCheck(%q) returned nil — code was removed from the SSOT-frozen enum", code)
}
})
}
}