mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 08:12:36 +08:00
Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.
Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift
Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.
Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.
At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.
First PR in the feat/error-contract-* series.
115 lines
3.9 KiB
Go
115 lines
3.9 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
)
|
|
|
|
// TestTryHandleMCPResponse_RecognisesDataCode pins the parser's primary path:
|
|
// when the outer `error.code` carries a JSON-RPC status (e.g. -32603) and the
|
|
// Lark numeric code lives in `error.data.code`, the transport reads `data.code`
|
|
// to look up the codeMeta and converts the response into *errs.SecurityPolicyError.
|
|
// This shape is forward-compat for a future server-side migration to the
|
|
// JSON-RPC-canonical layout; see also TestTryHandleMCPResponse_FallsBackToOuterCode
|
|
// for the shape observed in production today.
|
|
func TestTryHandleMCPResponse_RecognisesDataCode(t *testing.T) {
|
|
t.Parallel()
|
|
transport := &SecurityPolicyTransport{}
|
|
|
|
result := map[string]interface{}{
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"error": map[string]interface{}{
|
|
"code": -32603, // JSON-RPC internal error
|
|
"message": "challenge required",
|
|
"data": map[string]interface{}{
|
|
"code": 21000, // Lark code for challenge_required
|
|
"type": "policy",
|
|
"subtype": "challenge_required",
|
|
"challenge_url": "https://example.com/challenge",
|
|
"hint": "please complete the challenge in your browser",
|
|
},
|
|
},
|
|
}
|
|
|
|
got := transport.tryHandleMCPResponse(result)
|
|
var spErr *errs.SecurityPolicyError
|
|
if !errors.As(got, &spErr) {
|
|
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
|
|
}
|
|
if spErr.Code != 21000 {
|
|
t.Errorf("Code = %d, want 21000", spErr.Code)
|
|
}
|
|
if spErr.Subtype != errs.SubtypeChallengeRequired {
|
|
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeChallengeRequired)
|
|
}
|
|
if spErr.ChallengeURL != "https://example.com/challenge" {
|
|
t.Errorf("ChallengeURL = %q", spErr.ChallengeURL)
|
|
}
|
|
if spErr.Hint != "please complete the challenge in your browser" {
|
|
t.Errorf("Hint = %q", spErr.Hint)
|
|
}
|
|
}
|
|
|
|
// TestTryHandleMCPResponse_FallsBackToOuterCode pins the inbound shape observed
|
|
// in production from the MCP gateway: the Lark code sits in the outer
|
|
// `error.code` slot (no `data.code`), and the hint surfaces as `data.cli_hint`.
|
|
// The transport's outer-code fallback path must recognise the policy code and
|
|
// surface the typed error with the hint promoted.
|
|
func TestTryHandleMCPResponse_FallsBackToOuterCode(t *testing.T) {
|
|
t.Parallel()
|
|
transport := &SecurityPolicyTransport{}
|
|
|
|
result := map[string]interface{}{
|
|
"error": map[string]interface{}{
|
|
"code": 21001, // outer slot carries the Lark code
|
|
"message": "access denied",
|
|
"data": map[string]interface{}{
|
|
"challenge_url": "https://example.com/c",
|
|
"cli_hint": "contact admin",
|
|
},
|
|
},
|
|
}
|
|
|
|
got := transport.tryHandleMCPResponse(result)
|
|
var spErr *errs.SecurityPolicyError
|
|
if !errors.As(got, &spErr) {
|
|
t.Fatalf("expected *errs.SecurityPolicyError, got %T (err = %v)", got, got)
|
|
}
|
|
if spErr.Subtype != errs.SubtypeAccessDenied {
|
|
t.Errorf("Subtype = %q, want %q", spErr.Subtype, errs.SubtypeAccessDenied)
|
|
}
|
|
// `cli_hint` must surface when `hint` is absent.
|
|
if spErr.Hint != "contact admin" {
|
|
t.Errorf("Hint = %q, want fallback from cli_hint", spErr.Hint)
|
|
}
|
|
}
|
|
|
|
// TestTryHandleMCPResponse_NonPolicyCodeIgnored verifies the transport returns
|
|
// nil (passes through) when the Lark code does not classify as
|
|
// CategoryPolicy — keeps regular API errors out of the security-policy path.
|
|
func TestTryHandleMCPResponse_NonPolicyCodeIgnored(t *testing.T) {
|
|
t.Parallel()
|
|
transport := &SecurityPolicyTransport{}
|
|
|
|
result := map[string]interface{}{
|
|
"error": map[string]interface{}{
|
|
"code": -32603,
|
|
"message": "permission denied",
|
|
"data": map[string]interface{}{
|
|
"code": 99991672, // app_scope_not_enabled — Authorization, not Policy
|
|
"type": "authorization",
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := transport.tryHandleMCPResponse(result); err != nil {
|
|
t.Fatalf("expected nil (non-policy code), got %v", err)
|
|
}
|
|
}
|