mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Calendar commands now return structured, typed error envelopes for every failure mode — input validation, internal faults, and API responses — instead of legacy generic errors. Callers and AI agents get consistent exit codes and a machine-readable shape (type / subtype / code / hint), and can tell bad input, an internal fault, and an API rejection apart. Validation errors are attributed to the offending flag. Server-supplied error details (e.g. why an event time was rejected) are surfaced on the typed error's hint via a shared classifier improvement that benefits every domain. Multi-step operations (create-with-attendees rollback, multi-field update) preserve the real failure's classification and report which steps completed. The whole calendar domain is now lint-locked against reintroducing legacy error constructors.
1101 lines
42 KiB
Go
1101 lines
42 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package errclass_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/errclass"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
// missingScopeResp builds a minimal Lark missing-scope response with one
|
|
// violation. Shared across the envelope-shape and brand-switch tests.
|
|
func missingScopeResp(scope string) map[string]any {
|
|
return map[string]any{
|
|
"code": 99991679,
|
|
"msg": "scope missing",
|
|
"error": map[string]any{
|
|
"permission_violations": []any{
|
|
map[string]any{"subject": scope},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// appScopeNotAppliedResp builds the Lark response shape for code 99991672
|
|
// ("the app has not applied for the required scope(s)"). Used by tests that
|
|
// exercise the bot-perspective ConsoleURL attachment path, which the
|
|
// dispatcher restricts to SubtypeAppScopeNotApplied only.
|
|
func appScopeNotAppliedResp(scope string) map[string]any {
|
|
return map[string]any{
|
|
"code": 99991672,
|
|
"msg": "app scope not applied",
|
|
"error": map[string]any{
|
|
"permission_violations": []any{
|
|
map[string]any{"subject": scope},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestBuildAPIError_NilAndZeroCode(t *testing.T) {
|
|
if got := errclass.BuildAPIError(nil, errclass.ClassifyContext{}); got != nil {
|
|
t.Errorf("nil resp should return nil error, got %v", got)
|
|
}
|
|
if got := errclass.BuildAPIError(map[string]any{"code": 0, "msg": "ok"}, errclass.ClassifyContext{}); got != nil {
|
|
t.Errorf("code=0 should return nil error, got %v", got)
|
|
}
|
|
// json.Number 0 path (real-world SDK decodes with UseNumber)
|
|
resp := map[string]any{"code": json.Number("0"), "msg": "ok"}
|
|
if got := errclass.BuildAPIError(resp, errclass.ClassifyContext{}); got != nil {
|
|
t.Errorf("json.Number(0) should return nil error, got %v", got)
|
|
}
|
|
}
|
|
|
|
// matchesTypedError reports whether err is the typed-error variant identified by
|
|
// wantTyped (e.g. "ValidationError" → *errs.ValidationError). Used by the
|
|
// ExitCode matrix so a wrong-Category routing (e.g. CategoryValidation falling
|
|
// through to *APIError) fails loudly instead of passing on Category alone.
|
|
func matchesTypedError(err error, wantTyped string) bool {
|
|
switch wantTyped {
|
|
case "PermissionError":
|
|
var x *errs.PermissionError
|
|
return errors.As(err, &x)
|
|
case "AuthenticationError":
|
|
var x *errs.AuthenticationError
|
|
return errors.As(err, &x)
|
|
case "ValidationError":
|
|
var x *errs.ValidationError
|
|
return errors.As(err, &x)
|
|
case "NetworkError":
|
|
var x *errs.NetworkError
|
|
return errors.As(err, &x)
|
|
case "ConfigError":
|
|
var x *errs.ConfigError
|
|
return errors.As(err, &x)
|
|
case "InternalError":
|
|
var x *errs.InternalError
|
|
return errors.As(err, &x)
|
|
case "ConfirmationRequiredError":
|
|
var x *errs.ConfirmationRequiredError
|
|
return errors.As(err, &x)
|
|
case "SecurityPolicyError":
|
|
var x *errs.SecurityPolicyError
|
|
return errors.As(err, &x)
|
|
case "APIError":
|
|
// APIError is the default fallback; use a direct type assertion to avoid
|
|
// matching against typed subclasses that also satisfy IsAPI.
|
|
_, ok := err.(*errs.APIError)
|
|
return ok
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
code int
|
|
wantCat errs.Category
|
|
wantSubtype errs.Subtype
|
|
wantExit int
|
|
wantTyped string
|
|
}{
|
|
{"99991672 app_missing_scope", 99991672, errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, 3, "PermissionError"},
|
|
{"99991676 token_no_permission", 99991676, errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, 3, "PermissionError"},
|
|
{"99991679 missing_scope", 99991679, errs.CategoryAuthorization, errs.SubtypeMissingScope, 3, "PermissionError"},
|
|
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
|
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
|
|
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
|
|
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
|
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
|
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
|
{"unknown code 999999", 999999, errs.CategoryAPI, errs.SubtypeUnknown, 1, "APIError"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resp := map[string]any{"code": tc.code, "msg": "x"}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
|
|
if err == nil {
|
|
t.Fatalf("expected error for code %d, got nil", tc.code)
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("ProblemOf returned !ok for code %d (err = %T)", tc.code, err)
|
|
}
|
|
if p.Category != tc.wantCat {
|
|
t.Errorf("Category = %q, want %q", p.Category, tc.wantCat)
|
|
}
|
|
if p.Subtype != tc.wantSubtype {
|
|
t.Errorf("Subtype = %q, want %q", p.Subtype, tc.wantSubtype)
|
|
}
|
|
if got := output.ExitCodeOf(err); got != tc.wantExit {
|
|
t.Errorf("ExitCodeOf = %d, want %d (typed = %s)", got, tc.wantExit, tc.wantTyped)
|
|
}
|
|
if !matchesTypedError(err, tc.wantTyped) {
|
|
t.Errorf("typed-error mismatch: got %T, want %s", err, tc.wantTyped)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_TaskInvalidParamsRoutesToAPIError pins that code 1470400
|
|
// (Lark API-side parameter rejection) routes to *errs.APIError + CategoryAPI
|
|
// + SubtypeInvalidParameters. CategoryValidation is reserved for CLI-side
|
|
// (caller-side) flag/arg validation, never reachable from API responses;
|
|
// classify_test pins the API-side classification here so a regression that
|
|
// re-introduces the misclassification fails fast.
|
|
func TestBuildAPIError_TaskInvalidParamsRoutesToAPIError(t *testing.T) {
|
|
resp := map[string]any{"code": 1470400, "msg": "bad params"}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
|
if err == nil {
|
|
t.Fatal("expected error for code 1470400")
|
|
}
|
|
var ae *errs.APIError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatal("ProblemOf returned !ok")
|
|
}
|
|
if p.Category != errs.CategoryAPI {
|
|
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryAPI)
|
|
}
|
|
if p.Subtype != errs.SubtypeInvalidParameters {
|
|
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_TroubleshooterLiftedOnAPIArm pins that BuildAPIError lifts
|
|
// resp.error.troubleshooter into Problem.Troubleshooter when the response
|
|
// routes to the catch-all CategoryAPI arm. troubleshooter is the only
|
|
// resp.error field with genuinely non-redundant content vs typed envelope
|
|
// fields; the rest (permission_violations.subject, log_id, challenge_url) is
|
|
// already lifted by category-specific paths.
|
|
func TestBuildAPIError_TroubleshooterLiftedOnAPIArm(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": 1470400,
|
|
"msg": "bad params",
|
|
"error": map[string]any{
|
|
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/x",
|
|
},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatal("ProblemOf returned !ok")
|
|
}
|
|
if p.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/x" {
|
|
t.Errorf("Troubleshooter = %q, want passthrough", p.Troubleshooter)
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_TroubleshooterLiftedOnPermissionArm pins that
|
|
// troubleshooter surfaces on classified non-API arms too — BuildAPIError lifts
|
|
// it before the category switch so PermissionError / ConfigError / etc. inherit
|
|
// the same wire vocab.
|
|
func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": 99991679,
|
|
"msg": "missing scope",
|
|
"error": map[string]any{
|
|
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/scope",
|
|
"permission_violations": []any{map[string]any{"subject": "docx:document"}},
|
|
},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Identity: "user"})
|
|
var pe *errs.PermissionError
|
|
if !errors.As(err, &pe) {
|
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
|
}
|
|
if pe.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/scope" {
|
|
t.Errorf("Troubleshooter = %q, want lifted on PermissionError", pe.Troubleshooter)
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_DetailsLiftedToHintOnAPIArm pins that BuildAPIError lifts
|
|
// resp.error.details[].value into Problem.Hint when the response routes to the
|
|
// catch-all CategoryAPI arm. The real Lark shape (verified for code 190014) is
|
|
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}
|
|
// — only a human-readable reason string, no machine-readable field name. It is
|
|
// lifted into Hint (sanctioned free-text recovery prompt) rather than fabricated
|
|
// structured params.
|
|
func TestBuildAPIError_DetailsLiftedToHintOnAPIArm(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": 190014,
|
|
"msg": "invalid params",
|
|
"error": map[string]any{
|
|
"details": []any{
|
|
map[string]any{"value": "end_time should be later than start_time"},
|
|
},
|
|
},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatal("ProblemOf returned !ok")
|
|
}
|
|
if !strings.Contains(p.Hint, "end_time should be later than start_time") {
|
|
t.Errorf("Hint = %q, want it to contain the server detail value", p.Hint)
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_MultipleDetailsJoinedIntoHint pins that multiple non-empty
|
|
// detail values are joined with "; " into a single Hint, and empty values are
|
|
// skipped.
|
|
func TestBuildAPIError_MultipleDetailsJoinedIntoHint(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": 190014,
|
|
"msg": "invalid params",
|
|
"error": map[string]any{
|
|
"details": []any{
|
|
map[string]any{"value": "first reason"},
|
|
map[string]any{"value": ""},
|
|
map[string]any{"value": "second reason"},
|
|
},
|
|
},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatal("ProblemOf returned !ok")
|
|
}
|
|
if p.Hint != "first reason; second reason" {
|
|
t.Errorf("Hint = %q, want %q", p.Hint, "first reason; second reason")
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_DetailsSkipsNonMapEntries pins that malformed entries in
|
|
// the details array (not a JSON object) are skipped rather than panicking, and
|
|
// well-formed siblings still surface in the Hint.
|
|
func TestBuildAPIError_DetailsSkipsNonMapEntries(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": 190014,
|
|
"msg": "invalid params",
|
|
"error": map[string]any{
|
|
"details": []any{
|
|
"i am a bare string, not an object",
|
|
map[string]any{"value": "the real reason"},
|
|
42,
|
|
},
|
|
},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatal("ProblemOf returned !ok")
|
|
}
|
|
if p.Hint != "the real reason" {
|
|
t.Errorf("Hint = %q, want %q", p.Hint, "the real reason")
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_DetailsMalformedShapesNoHint pins that a missing error
|
|
// block, a non-array details field, and an empty details array all leave the
|
|
// Hint untouched (no lifted detail) instead of erroring.
|
|
func TestBuildAPIError_DetailsMalformedShapesNoHint(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
resp map[string]any
|
|
}{
|
|
{"no error block", map[string]any{"code": 190014, "msg": "invalid params"}},
|
|
{"details not array", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": "nope"}}},
|
|
{"empty details", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{}}}},
|
|
{"detail values all empty", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{map[string]any{"value": ""}}}}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatal("ProblemOf returned !ok")
|
|
}
|
|
// With no liftable detail, the Hint must not echo a server detail.
|
|
if strings.Contains(p.Hint, "nope") {
|
|
t.Errorf("Hint should not lift a non-array details field, got %q", p.Hint)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
|
|
// when the upstream response omits it — wire envelope must omit the field.
|
|
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {
|
|
resp := map[string]any{"code": 1470400, "msg": "bad params"}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatal("ProblemOf returned !ok")
|
|
}
|
|
if p.Troubleshooter != "" {
|
|
t.Errorf("Troubleshooter = %q, want empty when resp omits it", p.Troubleshooter)
|
|
}
|
|
}
|
|
|
|
func TestPermissionErrorEnvelopeShape(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": 99991679,
|
|
"msg": "missing scope",
|
|
"log_id": "lg-1",
|
|
"error": map[string]any{
|
|
"permission_violations": []any{
|
|
map[string]any{"subject": "docx:document"},
|
|
},
|
|
},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
|
|
|
var buf bytes.Buffer
|
|
ok := output.WriteTypedErrorEnvelope(&buf, err, "user")
|
|
if !ok {
|
|
t.Fatal("WriteTypedErrorEnvelope returned false for typed error")
|
|
}
|
|
out := buf.String()
|
|
|
|
// positive assertions
|
|
for _, want := range []string{
|
|
`"type": "authorization"`,
|
|
`"subtype": "missing_scope"`,
|
|
`"code": 99991679`,
|
|
`"missing_scopes":`,
|
|
`"docx:document"`,
|
|
`"identity": "user"`,
|
|
`"log_id": "lg-1"`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("envelope missing %q\nfull: %s", want, out)
|
|
}
|
|
}
|
|
// negative assertions on the wire format
|
|
for _, mustNot := range []string{
|
|
`"component"`,
|
|
`"doc_url"`,
|
|
`"retryable":`, // Retryable defaults false, omitempty → key absent
|
|
// console_url is gated to SubtypeAppScopeNotApplied (bot-perspective
|
|
// dev-action recovery). For user-perspective missing_scope the only
|
|
// actionable recovery is `lark-cli auth login --scope ...` (already
|
|
// in Hint), so the URL is dropped from the wire to avoid pointing an
|
|
// end user at a console they cannot modify.
|
|
`"console_url":`,
|
|
} {
|
|
if strings.Contains(out, mustNot) {
|
|
t.Errorf("envelope must not contain %q\nfull: %s", mustNot, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRetryableEnvelope_TrueOnly(t *testing.T) {
|
|
// Test 1: Retryable:true → key present
|
|
apiErr := &errs.APIError{Problem: errs.Problem{
|
|
Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Message: "x", Retryable: true,
|
|
}}
|
|
var buf bytes.Buffer
|
|
output.WriteTypedErrorEnvelope(&buf, apiErr, "user")
|
|
if !strings.Contains(buf.String(), `"retryable": true`) {
|
|
t.Errorf("Retryable:true should emit key; got: %s", buf.String())
|
|
}
|
|
|
|
// Test 2: Retryable:false → key absent
|
|
buf.Reset()
|
|
apiErr2 := &errs.APIError{Problem: errs.Problem{
|
|
Category: errs.CategoryAPI, Message: "x", Retryable: false,
|
|
}}
|
|
if ok := output.WriteTypedErrorEnvelope(&buf, apiErr2, "user"); !ok {
|
|
t.Fatal("WriteTypedErrorEnvelope returned false for typed error — emission failed silently")
|
|
}
|
|
if strings.Contains(buf.String(), `"retryable"`) {
|
|
t.Errorf("Retryable:false should omit key; got: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestConsoleURL_FeishuBrand(t *testing.T) {
|
|
resp := appScopeNotAppliedResp("docx:document")
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
|
|
pe, ok := err.(*errs.PermissionError)
|
|
if !ok {
|
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
|
}
|
|
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
|
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
|
}
|
|
}
|
|
|
|
func TestConsoleURL_LarkBrand(t *testing.T) {
|
|
resp := appScopeNotAppliedResp("docx:document")
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "bot"})
|
|
pe, ok := err.(*errs.PermissionError)
|
|
if !ok {
|
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
|
}
|
|
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
|
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
|
}
|
|
}
|
|
|
|
func TestConsoleURL_EmptyAppID(t *testing.T) {
|
|
resp := appScopeNotAppliedResp("docx:document")
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "bot"})
|
|
pe := err.(*errs.PermissionError)
|
|
if pe.ConsoleURL != "" {
|
|
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL)
|
|
}
|
|
}
|
|
|
|
// TestConsoleURL_AttachedOnlyForAppScopeNotApplied pins the gating rule:
|
|
// the developer-console deep-link only rides on the wire for
|
|
// SubtypeAppScopeNotApplied (where the recovery is "developer applies the
|
|
// scope"). User-perspective subtypes such as SubtypeMissingScope recover via
|
|
// `lark-cli auth login --scope ...`, so the URL is dead weight on those
|
|
// envelopes and is intentionally omitted to avoid pointing an end user at a
|
|
// console they cannot modify.
|
|
func TestConsoleURL_AttachedOnlyForAppScopeNotApplied(t *testing.T) {
|
|
cc := errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"}
|
|
|
|
bot := errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc).(*errs.PermissionError)
|
|
if bot.ConsoleURL == "" {
|
|
t.Errorf("SubtypeAppScopeNotApplied envelope must carry ConsoleURL; got empty")
|
|
}
|
|
|
|
user := errclass.BuildAPIError(missingScopeResp("docx:document"),
|
|
errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}).(*errs.PermissionError)
|
|
if user.ConsoleURL != "" {
|
|
t.Errorf("SubtypeMissingScope envelope must NOT carry ConsoleURL; got %q", user.ConsoleURL)
|
|
}
|
|
}
|
|
|
|
// TestConsoleURL_EscapesDangerousChars pins that ConsoleURL escapes appID and
|
|
// scope values so a hostile value cannot break out of the URL framing
|
|
// (e.g. by smuggling extra `&` parameters or a `#` fragment).
|
|
func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
appID string
|
|
scopes []string
|
|
wantInURL []string // substrings that MUST appear
|
|
denyInURL []string // substrings that MUST NOT appear
|
|
}{
|
|
{
|
|
name: "ampersand in scope smuggles extra param",
|
|
appID: "cli_good",
|
|
scopes: []string{"scope&evil=injected"},
|
|
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
|
denyInURL: []string{"q=scope&evil=injected"},
|
|
},
|
|
{
|
|
name: "hash in scope splits fragment",
|
|
appID: "cli_good",
|
|
scopes: []string{"scope#fragment"},
|
|
wantInURL: []string{"q=scope%23fragment"},
|
|
denyInURL: []string{"q=scope#fragment"},
|
|
},
|
|
{
|
|
name: "question mark in appID prematurely opens query",
|
|
appID: "good?q=injected",
|
|
scopes: []string{"docx:document"},
|
|
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
|
denyInURL: []string{"/app/good?q=injected/auth"},
|
|
},
|
|
{
|
|
name: "hash in appID truncates URL",
|
|
appID: "good#fragment",
|
|
scopes: []string{"docx:document"},
|
|
wantInURL: []string{"/app/good%23fragment/auth"},
|
|
denyInURL: []string{"/app/good#fragment/auth"},
|
|
},
|
|
{
|
|
name: "slash in appID escapes path segment",
|
|
appID: "good/extra/segment",
|
|
scopes: []string{"docx:document"},
|
|
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := errclass.ConsoleURL("feishu", tt.appID, tt.scopes)
|
|
for _, want := range tt.wantInURL {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("ConsoleURL missing escaped substring\n want: %s\n got: %s", want, got)
|
|
}
|
|
}
|
|
for _, deny := range tt.denyInURL {
|
|
if strings.Contains(got, deny) {
|
|
t.Errorf("ConsoleURL contains unescaped dangerous substring\n deny: %s\n got: %s", deny, got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPermissionError_DefaultIdentity(t *testing.T) {
|
|
resp := missingScopeResp("docx:document")
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123" /* no Identity */})
|
|
pe := err.(*errs.PermissionError)
|
|
if pe.Identity != "user" {
|
|
t.Errorf("default Identity should be \"user\"; got %q", pe.Identity)
|
|
}
|
|
}
|
|
|
|
func TestPermissionError_NoViolations(t *testing.T) {
|
|
// permission error without a permission_violations array → MissingScopes nil,
|
|
// ConsoleURL falls back to the no-scope form. Exercises the bot-perspective
|
|
// SubtypeAppScopeNotApplied envelope since that is where ConsoleURL rides.
|
|
resp := map[string]any{"code": 99991672, "msg": "x"}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
|
|
pe := err.(*errs.PermissionError)
|
|
if pe.MissingScopes != nil {
|
|
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
|
}
|
|
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
|
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
|
}
|
|
}
|
|
|
|
func TestExtractMissingScopes_Dedup(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": 99991679,
|
|
"msg": "x",
|
|
"error": map[string]any{
|
|
"permission_violations": []any{
|
|
map[string]any{"subject": "docx:document"},
|
|
map[string]any{"subject": "docx:document"}, // dup
|
|
map[string]any{"subject": ""}, // ignored
|
|
map[string]any{"subject": "im:message"},
|
|
},
|
|
},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
|
pe := err.(*errs.PermissionError)
|
|
if got, want := len(pe.MissingScopes), 2; got != want {
|
|
t.Fatalf("MissingScopes len = %d, want %d (raw: %v)", got, want, pe.MissingScopes)
|
|
}
|
|
}
|
|
|
|
// TestServiceShortcutEnvelopeConverge guards that the wire envelope produced
|
|
// by the dispatcher (BuildAPIError — the normal service / shortcut path)
|
|
// converges with the envelope produced by the direct-construction path used
|
|
// in cmd/service/service.go's checkServiceScopes pre-flight check.
|
|
//
|
|
// Both paths now share the same canonical helpers in internal/errclass for
|
|
// Message (CanonicalPermissionMessage), Hint (PermissionHint), and
|
|
// ConsoleURL (ConsoleURL); MissingScopes and Identity are filled identically.
|
|
// A future drift on either side (e.g. a new extension field on
|
|
// PermissionError that only BuildAPIError populates, or service.go inlining
|
|
// its own message string again) fails this test loudly.
|
|
//
|
|
// One upstream-derived field is a documented exception: `code` (the Lark
|
|
// API numeric code). The pre-flight check runs against a locally cached
|
|
// scope list and has no upstream response to extract it from. The
|
|
// comparison below strips that key from both envelopes so the assertion
|
|
// isolates the contract fields that MUST converge: Subtype, Category,
|
|
// Message, Hint, Identity, MissingScopes, ConsoleURL.
|
|
func TestServiceShortcutEnvelopeConverge(t *testing.T) {
|
|
const (
|
|
brand = "feishu"
|
|
appID = "cli_a123"
|
|
identity = "user"
|
|
)
|
|
missing := []string{"docx:document"}
|
|
|
|
// Path A: dispatcher — BuildAPIError parsing a Lark API response.
|
|
resp := missingScopeResp(missing[0])
|
|
dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity})
|
|
if _, ok := dispatcherErr.(*errs.PermissionError); !ok {
|
|
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
|
|
}
|
|
|
|
// Path B: direct construction — exercises the same helpers that
|
|
// cmd/service/service.go's newPreflightMissingScopeError uses. Keep this
|
|
// in lock-step with that helper; if either drifts the byte-comparison
|
|
// fails. ConsoleURL is intentionally NOT set on either path for
|
|
// SubtypeMissingScope — see the gating rationale in buildPermissionError.
|
|
consoleURL := errclass.ConsoleURL(brand, appID, missing)
|
|
directErr := errs.NewPermissionError(errs.SubtypeMissingScope,
|
|
"%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")).
|
|
WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)).
|
|
WithMissingScopes(missing...).
|
|
WithIdentity(identity)
|
|
|
|
var bufA, bufB bytes.Buffer
|
|
if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok {
|
|
t.Fatal("dispatcher path failed to emit typed envelope")
|
|
}
|
|
if ok := output.WriteTypedErrorEnvelope(&bufB, directErr, identity); !ok {
|
|
t.Fatal("direct path failed to emit typed envelope")
|
|
}
|
|
|
|
// Strip `code` from both envelopes — see test doc above.
|
|
stripA := stripUpstreamFields(t, bufA.Bytes())
|
|
stripB := stripUpstreamFields(t, bufB.Bytes())
|
|
if stripA != stripB {
|
|
t.Errorf("dispatcher vs direct-construction envelopes diverge (upstream fields stripped):\nDispatcher: %s\nDirect: %s", stripA, stripB)
|
|
}
|
|
}
|
|
|
|
// stripUpstreamFields parses an envelope JSON and re-marshals it with the
|
|
// upstream-derived "code" key removed from the inner "error" block. Used by
|
|
// the convergence test to isolate contract fields shared between the
|
|
// dispatcher and pre-flight paths.
|
|
func stripUpstreamFields(t *testing.T, raw []byte) string {
|
|
t.Helper()
|
|
var obj map[string]any
|
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
|
t.Fatalf("envelope not valid JSON: %v\nraw: %s", err, raw)
|
|
}
|
|
if errBlock, ok := obj["error"].(map[string]any); ok {
|
|
delete(errBlock, "code")
|
|
}
|
|
out, err := json.Marshal(obj)
|
|
if err != nil {
|
|
t.Fatalf("re-marshal failed: %v", err)
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func TestDirectPermissionPath_TypedExitCode(t *testing.T) {
|
|
// Mirrors what the cmd/service direct-construction path produces.
|
|
pe := &errs.PermissionError{
|
|
Problem: errs.Problem{
|
|
Category: errs.CategoryAuthorization,
|
|
Subtype: errs.SubtypeMissingScope,
|
|
Message: "missing required scope(s): docx:document",
|
|
},
|
|
MissingScopes: []string{"docx:document"},
|
|
Identity: "user",
|
|
}
|
|
if got := output.ExitCodeOf(pe); got != 3 {
|
|
t.Errorf("ExitCodeOf = %d, want 3", got)
|
|
}
|
|
if !errs.IsPermission(pe) {
|
|
t.Error("expected IsPermission(pe) == true")
|
|
}
|
|
}
|
|
|
|
func TestWriteTypedEnvelope_UntypedReturnsFalse(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
if output.WriteTypedErrorEnvelope(&buf, errors.New("plain"), "user") {
|
|
t.Error("expected WriteTypedErrorEnvelope to return false for untyped error")
|
|
}
|
|
if buf.Len() > 0 {
|
|
t.Errorf("expected no output for untyped error, got: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestBuildAPIError_LogIDNestedInError(t *testing.T) {
|
|
// Some Lark API responses carry log_id nested under "error" rather than
|
|
// at the top level. BuildAPIError must surface either location.
|
|
resp := map[string]any{
|
|
"code": 99991679,
|
|
"msg": "missing scope",
|
|
"error": map[string]any{
|
|
"log_id": "lg-nested-123",
|
|
},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_x", Identity: "user"})
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
|
}
|
|
if p.LogID != "lg-nested-123" {
|
|
t.Errorf("LogID = %q, want lg-nested-123", p.LogID)
|
|
}
|
|
}
|
|
|
|
func TestBuildAPIError_LogIDTopLevel(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": 99991679,
|
|
"msg": "missing scope",
|
|
"log_id": "lg-top-456",
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Identity: "user"})
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
|
}
|
|
if p.LogID != "lg-top-456" {
|
|
t.Errorf("LogID = %q, want lg-top-456", p.LogID)
|
|
}
|
|
}
|
|
|
|
func TestBuildPermissionHint_MissingScopeRoutesToAuthLogin(t *testing.T) {
|
|
// missing_scope means the user authorized the app but did not grant
|
|
// this scope — recoverable by re-running `auth login`. Both user and
|
|
// bot identities route the same way because the recovery action is
|
|
// user-initiated either way.
|
|
for _, identity := range []string{"user", "bot", ""} {
|
|
got := errclass.PermissionHint([]string{"docx:document", "im:message"}, identity, errs.SubtypeMissingScope, "")
|
|
if !strings.Contains(got, "lark-cli auth login") {
|
|
t.Errorf("identity=%q: hint should suggest `lark-cli auth login`; got %q", identity, got)
|
|
}
|
|
if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") {
|
|
t.Errorf("identity=%q: hint should include missing scopes; got %q", identity, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildPermissionHint_NoScopes(t *testing.T) {
|
|
// missing_scope with empty list — still suggests auth login even
|
|
// without the explicit --scope argument.
|
|
if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope, ""); !strings.Contains(got, "lark-cli auth login") {
|
|
t.Errorf("missing_scope no-scope hint should still suggest auth login; got %q", got)
|
|
}
|
|
// app_scope_not_applied without console URL — still points at the
|
|
// developer console (URL is optional context, not a routing axis).
|
|
if got := errclass.PermissionHint(nil, "user", errs.SubtypeAppScopeNotApplied, ""); !strings.Contains(got, "developer console") {
|
|
t.Errorf("app_scope_not_applied no-URL hint should still point at developer console; got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
|
// 99991672 / app_scope_not_applied means the scope has not been granted
|
|
// at the app level — re-authenticating cannot fix it. The hint must
|
|
// point to the developer console regardless of caller identity, or
|
|
// agents will loop on `auth login` forever.
|
|
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
|
|
for _, identity := range []string{"user", "bot", ""} {
|
|
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
|
if !strings.Contains(got, "developer console") {
|
|
t.Errorf("identity=%q: hint should point to developer console; got %q", identity, got)
|
|
}
|
|
if !strings.Contains(got, consoleURL) {
|
|
t.Errorf("identity=%q: hint should embed the console URL; got %q", identity, got)
|
|
}
|
|
if strings.Contains(got, "auth login") {
|
|
t.Errorf("identity=%q: hint must not suggest `auth login`; got %q", identity, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuildPermissionError_CanonicalMessage pins the per-subtype canonical
|
|
// wording so the wire envelope's Message preserves Lark's official phrasing
|
|
// ("access denied" / "unauthorized" / "token has no permission") and enhances
|
|
// it with CLI context (app ID, scope list). Regressions here are user-visible.
|
|
func TestBuildPermissionError_CanonicalMessage(t *testing.T) {
|
|
const appID = "cli_xyz"
|
|
cases := []struct {
|
|
name string
|
|
code int
|
|
wantSubtype errs.Subtype
|
|
// substrings the canonical message MUST contain
|
|
wantSubstrs []string
|
|
}{
|
|
{
|
|
name: "99991672 app_scope_not_applied",
|
|
code: 99991672,
|
|
wantSubtype: errs.SubtypeAppScopeNotApplied,
|
|
wantSubstrs: []string{"access denied", "app " + appID, "contact:contact"},
|
|
},
|
|
{
|
|
name: "99991679 missing_scope",
|
|
code: 99991679,
|
|
wantSubtype: errs.SubtypeMissingScope,
|
|
wantSubstrs: []string{"unauthorized", "user authorization", "contact:contact"},
|
|
},
|
|
{
|
|
name: "99991676 token_scope_insufficient",
|
|
code: 99991676,
|
|
wantSubtype: errs.SubtypeTokenScopeInsufficient,
|
|
wantSubstrs: []string{"token has no permission"},
|
|
},
|
|
{
|
|
name: "230027 user_unauthorized",
|
|
code: 230027,
|
|
wantSubtype: errs.SubtypeUserUnauthorized,
|
|
wantSubstrs: []string{"access denied for this operation"},
|
|
},
|
|
{
|
|
name: "99991673 app_unavailable",
|
|
code: 99991673,
|
|
wantSubtype: errs.SubtypeAppUnavailable,
|
|
wantSubstrs: []string{"unauthorized app", "app " + appID, "not properly installed"},
|
|
},
|
|
{
|
|
name: "99991662 app_disabled",
|
|
code: 99991662,
|
|
wantSubtype: errs.SubtypeAppDisabled,
|
|
wantSubstrs: []string{"app " + appID, "not in use", "currently disabled"},
|
|
},
|
|
{
|
|
name: "1470403 permission_denied",
|
|
code: 1470403,
|
|
wantSubtype: errs.SubtypePermissionDenied,
|
|
wantSubstrs: []string{"user lacks permission"},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": tc.code,
|
|
"msg": "upstream raw text — must be replaced",
|
|
"error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: appID, Identity: "user"})
|
|
pe, ok := err.(*errs.PermissionError)
|
|
if !ok {
|
|
t.Fatalf("expected *PermissionError, got %T", err)
|
|
}
|
|
if pe.Subtype != tc.wantSubtype {
|
|
t.Errorf("Subtype = %q, want %q", pe.Subtype, tc.wantSubtype)
|
|
}
|
|
for _, sub := range tc.wantSubstrs {
|
|
if !strings.Contains(pe.Message, sub) {
|
|
t.Errorf("Message %q missing substring %q", pe.Message, sub)
|
|
}
|
|
}
|
|
if pe.Message == "upstream raw text — must be replaced" {
|
|
t.Errorf("Message must be rewritten to canonical text, got upstream verbatim: %q", pe.Message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCanonicalPermissionMessage_FallbackOnUnknownSubtype pins that an unknown
|
|
// subtype (not in the per-subtype switch) preserves the upstream fallback
|
|
// instead of producing an empty Message.
|
|
func TestCanonicalPermissionMessage_FallbackOnUnknownSubtype(t *testing.T) {
|
|
got := errclass.CanonicalPermissionMessage(errs.SubtypeUnknown, "cli_x", nil, "upstream verbatim")
|
|
if got != "upstream verbatim" {
|
|
t.Errorf("unknown subtype should preserve fallback; got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestCanonicalPermissionMessage_EmptyAppIDStillReadable pins the no-app-id
|
|
// fallback wording so an early-init bootstrap path that produces a
|
|
// PermissionError without ClassifyContext.AppID still emits useful text.
|
|
func TestCanonicalPermissionMessage_EmptyAppIDStillReadable(t *testing.T) {
|
|
cases := []struct {
|
|
sub errs.Subtype
|
|
substr string
|
|
appIDIn string
|
|
}{
|
|
{errs.SubtypeAppScopeNotApplied, "app has not applied", ""},
|
|
{errs.SubtypeAppUnavailable, "app is not properly installed", ""},
|
|
{errs.SubtypeAppDisabled, "app is not in use", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
got := errclass.CanonicalPermissionMessage(tc.sub, tc.appIDIn, nil, "")
|
|
if !strings.Contains(got, tc.substr) {
|
|
t.Errorf("subtype=%s no-app-id message missing %q: got %q", tc.sub, tc.substr, got)
|
|
}
|
|
if strings.Contains(got, " app ") || strings.Contains(got, "app : ") {
|
|
t.Errorf("subtype=%s no-app-id message has double space placeholder: %q", tc.sub, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testing.T) {
|
|
// Regression: code 99991672 with user identity previously emitted
|
|
// `lark-cli auth login --scope ...` which sends agents into a re-auth
|
|
// loop because the missing scope is not yet enabled at the app level.
|
|
resp := map[string]any{
|
|
"code": 99991672,
|
|
"msg": "app scope not enabled",
|
|
"error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_x", Identity: "user"})
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
|
}
|
|
if p.Subtype != errs.SubtypeAppScopeNotApplied {
|
|
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeAppScopeNotApplied)
|
|
}
|
|
if !strings.Contains(p.Hint, "developer console") {
|
|
t.Errorf("Hint should route to developer console; got %q", p.Hint)
|
|
}
|
|
if strings.Contains(p.Hint, "auth login") {
|
|
t.Errorf("Hint must not suggest `auth login` for app-level scope errors; got %q", p.Hint)
|
|
}
|
|
}
|
|
|
|
func TestPermissionError_HintPopulated(t *testing.T) {
|
|
resp := missingScopeResp("docx:document")
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("ProblemOf returned !ok, err = %T", err)
|
|
}
|
|
if p.Hint == "" {
|
|
t.Error("PermissionError.Hint should be populated by BuildAPIError")
|
|
}
|
|
if !strings.Contains(p.Hint, "docx:document") {
|
|
t.Errorf("Hint should reference missing scope; got %q", p.Hint)
|
|
}
|
|
}
|
|
|
|
func TestBuildAPIError_JSONNumberCode(t *testing.T) {
|
|
// SDK parses with json.Number; verify intFromAny handles it.
|
|
resp := map[string]any{"code": json.Number("99991679"), "msg": "x"}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
|
if err == nil {
|
|
t.Fatal("expected error for json.Number-encoded code")
|
|
}
|
|
if _, ok := err.(*errs.PermissionError); !ok {
|
|
t.Errorf("expected *errs.PermissionError, got %T", err)
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_SecurityPolicyExtractsChallenge pins that policy responses
|
|
// passing through BuildAPIError keep the browser-challenge URL and hint —
|
|
// agents need challenge_url to drive the user through MFA / device-trust
|
|
// flows. Without extraction, the typed envelope is degenerate vs. what the
|
|
// internal/auth/transport.go HTTP-layer interceptor already produces.
|
|
func TestBuildAPIError_SecurityPolicyExtractsChallenge(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": 21000,
|
|
"msg": "challenge required",
|
|
"data": map[string]any{
|
|
"challenge_url": "https://passport.feishu.cn/challenge/xyz",
|
|
"hint": "complete MFA in the browser, then retry",
|
|
},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
|
|
spe, ok := err.(*errs.SecurityPolicyError)
|
|
if !ok {
|
|
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
|
}
|
|
if spe.ChallengeURL != "https://passport.feishu.cn/challenge/xyz" {
|
|
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/challenge/xyz", spe.ChallengeURL)
|
|
}
|
|
if spe.Hint != "complete MFA in the browser, then retry" {
|
|
t.Errorf("Hint = %q, want MFA hint", spe.Hint)
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint pins that responses
|
|
// using data.cli_hint still surface via Hint when data.hint is absent.
|
|
func TestBuildAPIError_SecurityPolicyHintFallsBackToCliHint(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": 21001,
|
|
"msg": "access denied",
|
|
"data": map[string]any{
|
|
"cli_hint": "ask your admin for elevated approval",
|
|
},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "user"})
|
|
spe, ok := err.(*errs.SecurityPolicyError)
|
|
if !ok {
|
|
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
|
}
|
|
if spe.Hint != "ask your admin for elevated approval" {
|
|
t.Errorf("Hint = %q, want cli_hint fallback", spe.Hint)
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge pins that an
|
|
// untrusted challenge_url (non-https) is dropped — same policy as
|
|
// internal/auth/transport.go isValidChallengeURL.
|
|
func TestBuildAPIError_SecurityPolicyDropsNonHTTPSChallenge(t *testing.T) {
|
|
cases := []string{
|
|
"http://attacker.example.com/challenge",
|
|
"javascript:alert(1)",
|
|
"ftp://example.com/challenge",
|
|
"not a url at all",
|
|
}
|
|
for _, bad := range cases {
|
|
t.Run(bad, func(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": 21000,
|
|
"msg": "challenge required",
|
|
"data": map[string]any{"challenge_url": bad, "hint": "h"},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
|
spe, ok := err.(*errs.SecurityPolicyError)
|
|
if !ok {
|
|
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
|
}
|
|
if spe.ChallengeURL != "" {
|
|
t.Errorf("ChallengeURL should be dropped for %q, got %q", bad, spe.ChallengeURL)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_SecurityPolicyNoData pins the no-data case — typed
|
|
// envelope still routes correctly with empty extension fields when the
|
|
// upstream response carries only code+msg.
|
|
func TestBuildAPIError_SecurityPolicyNoData(t *testing.T) {
|
|
resp := map[string]any{"code": 21000, "msg": "challenge required"}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
|
spe, ok := err.(*errs.SecurityPolicyError)
|
|
if !ok {
|
|
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
|
}
|
|
if spe.ChallengeURL != "" {
|
|
t.Errorf("ChallengeURL should be empty without data; got %q", spe.ChallengeURL)
|
|
}
|
|
if spe.Message != "challenge required" {
|
|
t.Errorf("Message = %q, want challenge required", spe.Message)
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_SecurityPolicyMalformedData pins that malformed `data`
|
|
// blocks (wrong type, wrong shape, non-string fields) degrade gracefully —
|
|
// extension fields stay empty, no panic. Server-side bugs or transitional
|
|
// API shapes must never crash the CLI dispatcher.
|
|
func TestBuildAPIError_SecurityPolicyMalformedData(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
resp map[string]any
|
|
}{
|
|
{"data is string not map", map[string]any{"code": 21000, "msg": "x", "data": "oops"}},
|
|
{"data is array not map", map[string]any{"code": 21000, "msg": "x", "data": []any{1, 2}}},
|
|
{"data is nil", map[string]any{"code": 21000, "msg": "x", "data": nil}},
|
|
{"challenge_url is int", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"challenge_url": 123}}},
|
|
{"challenge_url is nil", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"challenge_url": nil}}},
|
|
{"hint is array", map[string]any{"code": 21000, "msg": "x", "data": map[string]any{"hint": []any{"a"}}}},
|
|
{"error.data is wrong type", map[string]any{"code": 21000, "msg": "x", "error": map[string]any{"data": "oops"}}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("BuildAPIError panicked on malformed data: %v", r)
|
|
}
|
|
}()
|
|
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
|
|
spe, ok := err.(*errs.SecurityPolicyError)
|
|
if !ok {
|
|
t.Fatalf("expected *SecurityPolicyError even with malformed data, got %T", err)
|
|
}
|
|
if spe.ChallengeURL != "" {
|
|
t.Errorf("ChallengeURL should be empty for malformed data, got %q", spe.ChallengeURL)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildAPIError_SecurityPolicyErrorDataShape pins extraction from the
|
|
// {"error": {"data": {...}}} envelope variant — same lookup paths the
|
|
// transport-layer interceptor uses on inbound responses.
|
|
func TestBuildAPIError_SecurityPolicyErrorDataShape(t *testing.T) {
|
|
resp := map[string]any{
|
|
"code": 21000,
|
|
"msg": "challenge required",
|
|
"error": map[string]any{
|
|
"data": map[string]any{
|
|
"challenge_url": "https://passport.feishu.cn/c/abc",
|
|
"hint": "wrapped variant",
|
|
},
|
|
},
|
|
}
|
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
|
spe, ok := err.(*errs.SecurityPolicyError)
|
|
if !ok {
|
|
t.Fatalf("expected *SecurityPolicyError, got %T", err)
|
|
}
|
|
if spe.ChallengeURL != "https://passport.feishu.cn/c/abc" {
|
|
t.Errorf("ChallengeURL = %q, want https://passport.feishu.cn/c/abc", spe.ChallengeURL)
|
|
}
|
|
if spe.Hint != "wrapped variant" {
|
|
t.Errorf("Hint = %q, want wrapped variant", spe.Hint)
|
|
}
|
|
}
|