Files
larksuite__cli/lint/errscontract/rule_new_invariants_test.go
evandance 99e314fe0b feat(errs): typed envelope contract for auth-domain errors (#1135)
Every failure on the authentication, authorization, and configuration
path now surfaces as a typed structured error instead of an ad-hoc
envelope. Users and scripts that consume CLI output get:

  - a fixed nine-category taxonomy on the wire, each mapped to a
    stable shell exit code (authentication/authorization/config = 3,
    network = 4, internal = 5, policy = 6, confirmation = 10)
  - identity-aware detail fields (missing_scopes, requested_scopes,
    granted_scopes, console_url, log_id, retryable, hint) carried
    uniformly on the envelope
  - a single canonical policy envelope at exit 6; the legacy
    auth_error carve-out is retired
  - per-subtype canonical message + hint that preserves Lark's
    diagnostic phrasing and routes recovery to the right actor:
    app developer (app_scope_not_applied), user (missing_scope,
    token_scope_insufficient, user_unauthorized), or tenant admin
    (app_unavailable, app_disabled)
  - wrong app credentials classify as config/invalid_client whether
    surfaced by the Open API endpoint (99991543) or the tenant
    access-token mint endpoint (10003 / 10014), instead of
    collapsing to a transport error or api/unknown
  - local shortcut scope preflight emits the same
    authorization/missing_scope envelope (identity + deterministic
    missing-scope set) used by the post-call permission path, so AI
    consumers read the same structured shape from precheck and from
    server-returned permission denial
  - streaming download/upload failures keep the same network subtype
    split (timeout / TLS / DNS / transport) as the non-stream path
    instead of collapsing every cause to a generic transport failure
  - console_url is carried only on the bot-perspective
    app_scope_not_applied envelope (where the recovery action is
    "developer applies the scope at the developer console"); the
    user-perspective missing_scope envelope drops the field, since
    the only actionable user recovery is `lark-cli auth login --scope`
    and pointing an end user at a console they cannot modify is
    misleading
  - bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic
    Type tags to wire 'config' with the original module name kept
    as a metric label

All 10 typed errors are cause-bearing, nil-safe on .Error() and
.Unwrap(), and defensively clone slice setter inputs. Four lint
rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry
/ CheckBuildAPIErrorArms) lock these invariants on migrated paths.
2026-05-30 19:08:41 +08:00

601 lines
15 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"strings"
"testing"
)
// Tests for the four typed-error invariant rules:
// - CheckNilSafeError
// - CheckUnwrapSymmetry
// - CheckBuilderImmutable
// - CheckBuildAPIErrorArms
//
// Each rule gets a "rejects bad shape" + "accepts compliant shape" pair so
// future regressions reveal themselves immediately. Fixtures use the
// minimal canonical Problem-embedder shape and run through the public
// CheckXxx entry points — no internal helpers are exercised directly.
// =============================== CheckNilSafeError ===========================
func TestCheckNilSafeError_FlagsMissingOverride(t *testing.T) {
// BadError embeds Problem by value but defines no own Error() —
// the promoted Error() panics on a typed-nil interface holder.
src := `package errs
type Problem struct{}
func (p *Problem) Error() string { return "" }
type BadError struct {
Problem
}
`
v := CheckNilSafeError("errs/types.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "BadError") {
t.Errorf("message must name the type: %s", v[0].Message)
}
}
func TestCheckNilSafeError_FlagsMissingNilGuard(t *testing.T) {
// Error() exists but the first statement is not the nil-receiver guard.
src := `package errs
type Problem struct{}
func (p *Problem) Error() string { return "" }
type BadError struct {
Problem
}
func (e *BadError) Error() string {
return e.Problem.Error()
}
`
v := CheckNilSafeError("errs/types.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "nil-receiver guard") {
t.Errorf("message should call out the missing nil guard: %s", v[0].Message)
}
}
func TestCheckNilSafeError_AcceptsCompliantOverride(t *testing.T) {
src := `package errs
type Problem struct{}
func (p *Problem) Error() string { return "" }
type GoodError struct {
Problem
}
func (e *GoodError) Error() string {
if e == nil {
return ""
}
return e.Problem.Error()
}
`
v := CheckNilSafeError("errs/types.go", src)
if len(v) != 0 {
t.Errorf("compliant nil-safe Error() must pass, got: %+v", v)
}
}
func TestCheckNilSafeError_ScopedToErrsPackage(t *testing.T) {
// Same violating fixture outside errs/ — must NOT fire (the typed
// taxonomy is errs/-only).
src := `package custom
type Problem struct{}
func (p *Problem) Error() string { return "" }
type BadError struct {
Problem
}
`
v := CheckNilSafeError("internal/custom/x.go", src)
if len(v) != 0 {
t.Errorf("CheckNilSafeError must scope to errs/ only, got: %+v", v)
}
}
// ============================== CheckUnwrapSymmetry ==========================
func TestCheckUnwrapSymmetry_FlagsMissingUnwrap(t *testing.T) {
src := `package errs
type Problem struct{}
func (p *Problem) Error() string { return "" }
type BadError struct {
Problem
Cause error
}
`
v := CheckUnwrapSymmetry("errs/types.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "BadError") {
t.Errorf("message must name the type: %s", v[0].Message)
}
}
func TestCheckUnwrapSymmetry_FlagsUnwrapWithoutNilGuard(t *testing.T) {
src := `package errs
type Problem struct{}
func (p *Problem) Error() string { return "" }
type BadError struct {
Problem
Cause error
}
func (e *BadError) Unwrap() error {
return e.Cause
}
`
v := CheckUnwrapSymmetry("errs/types.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "nil-receiver guard") {
t.Errorf("message should call out the missing nil guard: %s", v[0].Message)
}
}
func TestCheckUnwrapSymmetry_AcceptsCompliantUnwrap(t *testing.T) {
src := `package errs
type Problem struct{}
func (p *Problem) Error() string { return "" }
type GoodError struct {
Problem
Cause error
}
func (e *GoodError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
`
v := CheckUnwrapSymmetry("errs/types.go", src)
if len(v) != 0 {
t.Errorf("compliant Unwrap() must pass, got: %+v", v)
}
}
// ============================= CheckBuilderImmutable =========================
func TestCheckBuilderImmutable_FlagsBareSliceAssignment(t *testing.T) {
src := `package errs
type Problem struct{}
type PermissionError struct {
Problem
MissingScopes []string
}
func (e *PermissionError) WithMissingScopes(scopes []string) *PermissionError {
e.MissingScopes = scopes
return e
}
`
v := CheckBuilderImmutable("errs/types.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "WithMissingScopes") || !strings.Contains(v[0].Message, "MissingScopes") {
t.Errorf("message should name the builder and field: %s", v[0].Message)
}
}
func TestCheckBuilderImmutable_FlagsBareVariadicAssignment(t *testing.T) {
// Variadic slice has the same aliasing hazard.
src := `package errs
type Problem struct{}
type PermissionError struct {
Problem
MissingScopes []string
}
func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError {
e.MissingScopes = scopes
return e
}
`
v := CheckBuilderImmutable("errs/types.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
}
func TestCheckBuilderImmutable_FlagsBareMapAssignment(t *testing.T) {
src := `package errs
type Problem struct{}
type APIError struct {
Problem
Detail map[string]any
}
func (e *APIError) WithDetail(detail map[string]any) *APIError {
e.Detail = detail
return e
}
`
v := CheckBuilderImmutable("errs/types.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "WithDetail") {
t.Errorf("message should name the builder: %s", v[0].Message)
}
}
func TestCheckBuilderImmutable_AcceptsClonedSlice(t *testing.T) {
src := `package errs
import "slices"
type Problem struct{}
type PermissionError struct {
Problem
MissingScopes []string
}
func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError {
e.MissingScopes = slices.Clone(scopes)
return e
}
`
v := CheckBuilderImmutable("errs/types.go", src)
if len(v) != 0 {
t.Errorf("slices.Clone wrap must pass, got: %+v", v)
}
}
func TestCheckBuilderImmutable_AcceptsClonedMap(t *testing.T) {
src := `package errs
import "maps"
type Problem struct{}
type APIError struct {
Problem
Detail map[string]any
}
func (e *APIError) WithDetail(detail map[string]any) *APIError {
e.Detail = maps.Clone(detail)
return e
}
`
v := CheckBuilderImmutable("errs/types.go", src)
if len(v) != 0 {
t.Errorf("maps.Clone wrap must pass, got: %+v", v)
}
}
func TestCheckBuilderImmutable_IgnoresScalarSetters(t *testing.T) {
// Scalar / string setters are not reference-typed; the rule must not
// false-positive on them.
src := `package errs
type Problem struct{}
type ConfigError struct {
Problem
Field string
}
func (e *ConfigError) WithField(field string) *ConfigError {
e.Field = field
return e
}
func (e *ConfigError) WithCode(code int) *ConfigError {
e.Code = code
return e
}
`
v := CheckBuilderImmutable("errs/types.go", src)
if len(v) != 0 {
t.Errorf("scalar setters must not fire, got: %+v", v)
}
}
// ============================ CheckBuildAPIErrorArms =========================
func TestCheckBuildAPIErrorArms_FlagsMissingCategory(t *testing.T) {
// Switch is missing the CategoryConfirmation arm.
src := `package errclass
import "github.com/larksuite/cli/errs"
type ClassifyContext struct{}
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
var cat errs.Category
switch cat {
case errs.CategoryValidation:
return &errs.ValidationError{}
case errs.CategoryAuthentication:
return &errs.AuthenticationError{}
case errs.CategoryAuthorization:
return &errs.PermissionError{}
case errs.CategoryConfig:
return &errs.ConfigError{}
case errs.CategoryNetwork:
return &errs.NetworkError{}
case errs.CategoryAPI:
return &errs.APIError{}
case errs.CategoryPolicy:
return &errs.SecurityPolicyError{}
case errs.CategoryInternal:
return &errs.InternalError{}
default:
return &errs.InternalError{}
}
}
`
v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation (missing CategoryConfirmation), got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "CategoryConfirmation") {
t.Errorf("message should name the missing Category: %s", v[0].Message)
}
}
func TestCheckBuildAPIErrorArms_FlagsMissingDefault(t *testing.T) {
src := `package errclass
import "github.com/larksuite/cli/errs"
type ClassifyContext struct{}
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
var cat errs.Category
switch cat {
case errs.CategoryValidation:
return &errs.ValidationError{}
case errs.CategoryAuthentication:
return &errs.AuthenticationError{}
case errs.CategoryAuthorization:
return &errs.PermissionError{}
case errs.CategoryConfig:
return &errs.ConfigError{}
case errs.CategoryNetwork:
return &errs.NetworkError{}
case errs.CategoryAPI:
return &errs.APIError{}
case errs.CategoryPolicy:
return &errs.SecurityPolicyError{}
case errs.CategoryInternal:
return &errs.InternalError{}
case errs.CategoryConfirmation:
return &errs.ConfirmationRequiredError{}
}
return nil
}
`
v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation (missing default arm), got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "no default arm") {
t.Errorf("message should call out the missing default: %s", v[0].Message)
}
}
func TestCheckBuildAPIErrorArms_FlagsNilReturningDefault(t *testing.T) {
src := `package errclass
import "github.com/larksuite/cli/errs"
type ClassifyContext struct{}
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
var cat errs.Category
switch cat {
case errs.CategoryValidation:
return &errs.ValidationError{}
case errs.CategoryAuthentication:
return &errs.AuthenticationError{}
case errs.CategoryAuthorization:
return &errs.PermissionError{}
case errs.CategoryConfig:
return &errs.ConfigError{}
case errs.CategoryNetwork:
return &errs.NetworkError{}
case errs.CategoryAPI:
return &errs.APIError{}
case errs.CategoryPolicy:
return &errs.SecurityPolicyError{}
case errs.CategoryInternal:
return &errs.InternalError{}
case errs.CategoryConfirmation:
return &errs.ConfirmationRequiredError{}
default:
return nil
}
}
`
v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation (default returns nil), got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "default arm") {
t.Errorf("message should call out the default arm: %s", v[0].Message)
}
}
func TestCheckBuildAPIErrorArms_AcceptsCompliantSwitch(t *testing.T) {
src := `package errclass
import "github.com/larksuite/cli/errs"
type ClassifyContext struct{}
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
var cat errs.Category
switch cat {
case errs.CategoryValidation:
return &errs.ValidationError{}
case errs.CategoryAuthentication:
return &errs.AuthenticationError{}
case errs.CategoryAuthorization:
return &errs.PermissionError{}
case errs.CategoryConfig:
return &errs.ConfigError{}
case errs.CategoryNetwork:
return &errs.NetworkError{}
case errs.CategoryAPI:
return &errs.APIError{}
case errs.CategoryPolicy:
return &errs.SecurityPolicyError{}
case errs.CategoryInternal:
return &errs.InternalError{}
case errs.CategoryConfirmation:
return &errs.ConfirmationRequiredError{}
default:
return &errs.InternalError{}
}
}
`
v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src)
if len(v) != 0 {
t.Errorf("compliant switch must pass, got: %+v", v)
}
}
func TestCheckBuildAPIErrorArms_RejectsWrongCategoryDefault(t *testing.T) {
src := `package errclass
import "github.com/larksuite/cli/errs"
type ClassifyContext struct{}
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
var cat errs.Category
switch cat {
case errs.CategoryValidation:
return &errs.ValidationError{}
case errs.CategoryAuthentication:
return &errs.AuthenticationError{}
case errs.CategoryAuthorization:
return &errs.PermissionError{}
case errs.CategoryConfig:
return &errs.ConfigError{}
case errs.CategoryNetwork:
return &errs.NetworkError{}
case errs.CategoryAPI:
return &errs.APIError{}
case errs.CategoryPolicy:
return &errs.SecurityPolicyError{}
case errs.CategoryInternal:
return &errs.InternalError{}
case errs.CategoryConfirmation:
return &errs.ConfirmationRequiredError{}
default:
return &errs.APIError{}
}
}
`
v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation (wrong-type default), got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "InternalError") {
t.Errorf("violation must call out InternalError requirement: %s", v[0].Message)
}
}
func TestCheckBuildAPIErrorArms_AcceptsNewInternalErrorConstructor(t *testing.T) {
src := `package errclass
import "github.com/larksuite/cli/errs"
type ClassifyContext struct{}
func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
var cat errs.Category
switch cat {
case errs.CategoryValidation:
return &errs.ValidationError{}
case errs.CategoryAuthentication:
return &errs.AuthenticationError{}
case errs.CategoryAuthorization:
return &errs.PermissionError{}
case errs.CategoryConfig:
return &errs.ConfigError{}
case errs.CategoryNetwork:
return &errs.NetworkError{}
case errs.CategoryAPI:
return &errs.APIError{}
case errs.CategoryPolicy:
return &errs.SecurityPolicyError{}
case errs.CategoryInternal:
return &errs.InternalError{}
case errs.CategoryConfirmation:
return &errs.ConfirmationRequiredError{}
default:
return errs.NewInternalError(errs.SubtypeSDKError, "unknown category")
}
}
`
v := CheckBuildAPIErrorArms("internal/errclass/classify.go", src)
if len(v) != 0 {
t.Errorf("constructor form must be accepted, got: %+v", v)
}
}
func TestCheckBuildAPIErrorArms_ScopedToClassifyFile(t *testing.T) {
// Identical violating shape outside the canonical path — must NOT fire.
src := `package custom
import "github.com/larksuite/cli/errs"
func BuildAPIError(resp map[string]any) error {
var cat errs.Category
switch cat {
case errs.CategoryValidation:
return nil
}
return nil
}
`
v := CheckBuildAPIErrorArms("internal/foo/other.go", src)
if len(v) != 0 {
t.Errorf("rule must scope to internal/errclass/classify.go, got: %+v", v)
}
}