mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
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.
386 lines
10 KiB
Go
386 lines
10 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package errscontract
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fixtureRepo lays out a tiny repo on tmpfs that mimics the live layout enough
|
|
// for ScanRepo / CheckErrsContract to exercise. Each entry is path → content.
|
|
type fixtureRepo map[string]string
|
|
|
|
func writeFixture(t *testing.T, files fixtureRepo) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
for rel, content := range files {
|
|
full := filepath.Join(root, rel)
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", full, err)
|
|
}
|
|
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", full, err)
|
|
}
|
|
}
|
|
return root
|
|
}
|
|
|
|
func TestLoadSubtypeAllowlist_ExtractsTypedConstValues(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/subtypes.go": `package errs
|
|
|
|
type Subtype string
|
|
|
|
const (
|
|
SubtypeMissingScope Subtype = "missing_scope"
|
|
SubtypeRateLimit Subtype = "rate_limit"
|
|
)
|
|
|
|
const (
|
|
UnrelatedConst = "ignore_me" // not Subtype-typed
|
|
)
|
|
`,
|
|
})
|
|
got, err := LoadSubtypeAllowlist(filepath.Join(root, "errs", "subtypes.go"))
|
|
if err != nil {
|
|
t.Fatalf("LoadSubtypeAllowlist: %v", err)
|
|
}
|
|
want := map[string]struct{}{"missing_scope": {}, "rate_limit": {}}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("size mismatch: got %d, want %d (%+v)", len(got), len(want), got)
|
|
}
|
|
for k := range want {
|
|
if _, ok := got[k]; !ok {
|
|
t.Errorf("missing %q in allowlist", k)
|
|
}
|
|
}
|
|
if _, ok := got["ignore_me"]; ok {
|
|
t.Errorf("untyped const leaked into allowlist")
|
|
}
|
|
}
|
|
|
|
// TestLoadSubtypeAllowlists_WalksAllSubtypesFiles pins the multi-file load:
|
|
// constants from every errs/subtypes*.go must contribute to both the values
|
|
// allowlist and the declared-names set.
|
|
func TestLoadSubtypeAllowlists_WalksAllSubtypesFiles(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/subtypes.go": `package errs
|
|
|
|
type Subtype string
|
|
|
|
const (
|
|
SubtypeMissingScope Subtype = "missing_scope"
|
|
)
|
|
`,
|
|
"errs/subtypes_extra.go": `package errs
|
|
|
|
const (
|
|
SubtypeExtraExample Subtype = "extra_example"
|
|
)
|
|
`,
|
|
})
|
|
values, names, err := LoadSubtypeAllowlists(filepath.Join(root, "errs"))
|
|
if err != nil {
|
|
t.Fatalf("LoadSubtypeAllowlists: %v", err)
|
|
}
|
|
for _, v := range []string{"missing_scope", "extra_example"} {
|
|
if _, ok := values[v]; !ok {
|
|
t.Errorf("values missing %q (across-file load broken)", v)
|
|
}
|
|
}
|
|
for _, n := range []string{"SubtypeMissingScope", "SubtypeExtraExample"} {
|
|
if _, ok := names[n]; !ok {
|
|
t.Errorf("names missing %q (across-file load broken)", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckErrsContract_FlagsMissingPredicateAndTest(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/types.go": `package errs
|
|
|
|
type Problem struct{}
|
|
|
|
type MissingError struct {
|
|
Problem
|
|
}
|
|
`,
|
|
"errs/predicates.go": `package errs
|
|
// IsMissing predicate intentionally absent
|
|
`,
|
|
// No errs/*_test.go file → MissingError lacks test coverage.
|
|
"internal/errclass/codemeta.go": `package errclass
|
|
|
|
type CodeMeta struct{}
|
|
|
|
var codeMeta = map[int]CodeMeta{1234: {}}
|
|
`,
|
|
})
|
|
v, err := CheckErrsContract(root)
|
|
if err != nil {
|
|
t.Fatalf("CheckErrsContract: %v", err)
|
|
}
|
|
var missingPredicate, missingTest int
|
|
for _, vv := range v {
|
|
switch {
|
|
case strings.Contains(vv.Message, "no matching IsMissing predicate"):
|
|
missingPredicate++
|
|
case strings.Contains(vv.Message, "no test exercising it"):
|
|
missingTest++
|
|
}
|
|
// Diagnostics emitted by CheckErrsContract must use repo-relative paths
|
|
// (same convention as walker-side rules), not absolute filesystem paths
|
|
// resolved via parser.ParseFile.
|
|
if strings.Contains(vv.Message, "MissingError") && vv.File != "errs/types.go" {
|
|
t.Errorf("violation File = %q, want repo-relative %q: %+v",
|
|
vv.File, "errs/types.go", vv)
|
|
}
|
|
}
|
|
if missingPredicate != 1 {
|
|
t.Errorf("missing-predicate diagnostics = %d, want 1: %+v", missingPredicate, v)
|
|
}
|
|
if missingTest != 1 {
|
|
t.Errorf("missing-test diagnostics = %d, want 1: %+v", missingTest, v)
|
|
}
|
|
}
|
|
|
|
func TestCheckErrsContract_AcceptsCompleteContract(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/types.go": `package errs
|
|
|
|
type Problem struct{}
|
|
|
|
type FooError struct{ Problem }
|
|
`,
|
|
"errs/predicates.go": `package errs
|
|
|
|
func IsFoo(err error) bool { return false }
|
|
`,
|
|
"errs/foo_test.go": `package errs_test
|
|
|
|
import "testing"
|
|
|
|
func TestFooError(t *testing.T) { _ = FooError{} }
|
|
`,
|
|
"internal/errclass/codemeta.go": `package errclass
|
|
|
|
type CodeMeta struct{}
|
|
|
|
var m = map[int]CodeMeta{42: {}}
|
|
`,
|
|
})
|
|
v, err := CheckErrsContract(root)
|
|
if err != nil {
|
|
t.Fatalf("CheckErrsContract: %v", err)
|
|
}
|
|
if len(v) != 0 {
|
|
t.Errorf("complete contract should pass, got %d violations: %+v", len(v), v)
|
|
}
|
|
}
|
|
|
|
func TestScanRepo_DetectsServiceRegistrarAndBadSubtype(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/types.go": `package errs
|
|
|
|
type Problem struct{}
|
|
|
|
type Subtype string
|
|
|
|
type FooError struct{ Problem }
|
|
`,
|
|
"errs/predicates.go": `package errs
|
|
|
|
func IsFoo(err error) bool { return false }
|
|
`,
|
|
"errs/foo_test.go": `package errs_test
|
|
import "testing"
|
|
func TestFooError(t *testing.T) { _ = FooError{} }
|
|
`,
|
|
"errs/subtypes.go": `package errs
|
|
|
|
const (
|
|
SubtypeKnown Subtype = "known"
|
|
)
|
|
`,
|
|
"internal/errclass/codemeta.go": `package errclass
|
|
|
|
type CodeMeta struct{}
|
|
|
|
var m = map[int]CodeMeta{1: {}}
|
|
`,
|
|
// Service file with a registrar AND a bad Subtype literal.
|
|
"shortcuts/task/bad.go": `package task
|
|
|
|
func init() {
|
|
mergeCodeMeta(nil, "task")
|
|
}
|
|
|
|
var _ = struct{ Subtype string }{Subtype: "not_known"}
|
|
`,
|
|
// Test files are exempt from C/D/E (rule pre-filter).
|
|
"shortcuts/task/bad_test.go": `package task
|
|
func placeholder() {}
|
|
`,
|
|
})
|
|
v, err := ScanRepo(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanRepo: %v", err)
|
|
}
|
|
var sawRegistrar, sawBadSubtype bool
|
|
for _, vv := range v {
|
|
if vv.Rule == "no_registrar" && strings.Contains(vv.File, "shortcuts/task/bad.go") {
|
|
sawRegistrar = true
|
|
}
|
|
if vv.Rule == "declared_subtype" && strings.Contains(vv.Message, "not_known") {
|
|
sawBadSubtype = true
|
|
}
|
|
}
|
|
if !sawRegistrar {
|
|
t.Errorf("ScanRepo missed CheckNoRegistrar registrar; got %+v", v)
|
|
}
|
|
if !sawBadSubtype {
|
|
t.Errorf("ScanRepo missed CheckDeclaredSubtype undeclared subtype; got %+v", v)
|
|
}
|
|
}
|
|
|
|
// TestScanRepo_EmitsAdvisoryWhenTypedScopeUnavailable pins Refinement 2:
|
|
// when a fixture LOOKS like a Go repo (has a go.mod) but typed loading
|
|
// cannot produce a usable errs.Subtype const set, ScanRepo emits a single
|
|
// ActionWarning advisory so reviewers know CheckDeclaredSubtype ran in a less-strict
|
|
// mode. ActionWarning is print-only — CI exit-code logic does not fail
|
|
// the run on it (proven by the lint main.go exit-code branch).
|
|
func TestScanRepo_EmitsAdvisoryWhenTypedScopeUnavailable(t *testing.T) {
|
|
// Fixture: a Go-looking repo (has go.mod) but errs/ contains a
|
|
// Subtype type with NO declared Subtype consts. LoadTypedScope will
|
|
// initialize but errsSubtypeConsts stays empty → Enabled() returns
|
|
// false under the tightened contract.
|
|
root := writeFixture(t, fixtureRepo{
|
|
"go.mod": "module example.com/fixture\n\ngo 1.23\n",
|
|
"errs/types.go": `package errs
|
|
|
|
type Problem struct{}
|
|
type Subtype string
|
|
type FooError struct{ Problem }
|
|
`,
|
|
"errs/predicates.go": `package errs
|
|
func IsFoo(err error) bool { return false }
|
|
`,
|
|
"errs/foo_test.go": `package errs_test
|
|
import "testing"
|
|
func TestFooError(t *testing.T) { _ = FooError{} }
|
|
`,
|
|
// subtypes.go is present so LoadSubtypeAllowlists succeeds, but the
|
|
// const block is empty so no values/names are declared.
|
|
"errs/subtypes.go": `package errs
|
|
|
|
const SubtypeKnown Subtype = "known"
|
|
`,
|
|
})
|
|
v, err := ScanRepo(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanRepo: %v", err)
|
|
}
|
|
|
|
advisoryCount := 0
|
|
for _, vv := range v {
|
|
if vv.Rule == "declared_subtype" && vv.Action == ActionWarning &&
|
|
strings.Contains(vv.Message, "typed resolution unavailable") {
|
|
advisoryCount++
|
|
}
|
|
}
|
|
if advisoryCount != 1 {
|
|
t.Errorf("advisory count = %d, want exactly 1; got violations: %+v", advisoryCount, v)
|
|
}
|
|
// The advisory must NOT escalate to REJECT — ActionWarning is print-only.
|
|
// (We don't assert rejectCount==0 in general since the fixture may emit
|
|
// other rejections; we only assert the advisory itself is a WARNING.)
|
|
for _, vv := range v {
|
|
if vv.Action == ActionReject && strings.Contains(vv.Message, "typed resolution unavailable") {
|
|
t.Errorf("advisory must be ActionWarning, not REJECT (would fail CI): %+v", vv)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScanRepo_NoAdvisoryWithoutGoMod pins the scoping: fixtures that lack
|
|
// a go.mod (the common unit-test shape) must NOT emit the advisory, since
|
|
// the workspace is not a Go repo from the loader's perspective.
|
|
func TestScanRepo_NoAdvisoryWithoutGoMod(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/types.go": `package errs
|
|
type Problem struct{}
|
|
type Subtype string
|
|
type FooError struct{ Problem }
|
|
`,
|
|
"errs/predicates.go": `package errs
|
|
func IsFoo(err error) bool { return false }
|
|
`,
|
|
"errs/foo_test.go": `package errs_test
|
|
import "testing"
|
|
func TestFooError(t *testing.T) { _ = FooError{} }
|
|
`,
|
|
"errs/subtypes.go": `package errs
|
|
const SubtypeKnown Subtype = "known"
|
|
`,
|
|
})
|
|
v, err := ScanRepo(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanRepo: %v", err)
|
|
}
|
|
for _, vv := range v {
|
|
if strings.Contains(vv.Message, "typed resolution unavailable") {
|
|
t.Errorf("no go.mod present → advisory must not fire; got %+v", vv)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScanRepo_LabelTriggerForAdHocSubtype(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/types.go": `package errs
|
|
type Problem struct{}
|
|
type Subtype string
|
|
type FooError struct{ Problem }
|
|
`,
|
|
"errs/predicates.go": `package errs
|
|
func IsFoo(err error) bool { return false }
|
|
`,
|
|
"errs/foo_test.go": `package errs_test
|
|
import "testing"
|
|
func TestFooError(t *testing.T) { _ = FooError{} }
|
|
`,
|
|
"errs/subtypes.go": `package errs
|
|
const (
|
|
SubtypeKnown Subtype = "known"
|
|
)
|
|
`,
|
|
"internal/errclass/codemeta.go": `package errclass
|
|
type CodeMeta struct{}
|
|
var m = map[int]CodeMeta{}
|
|
`,
|
|
"shortcuts/task/maybe.go": `package task
|
|
var _ = struct{ Subtype string }{Subtype: "ad_hoc_quota_breach"}
|
|
`,
|
|
})
|
|
v, err := ScanRepo(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanRepo: %v", err)
|
|
}
|
|
var sawLabel bool
|
|
for _, vv := range v {
|
|
if vv.Action == ActionLabel &&
|
|
strings.Contains(vv.Message, "needs-taxonomy-decision") {
|
|
sawLabel = true
|
|
}
|
|
if vv.Action == ActionReject &&
|
|
strings.Contains(vv.Message, "ad_hoc_quota_breach") {
|
|
t.Errorf("ad_hoc_* must NOT be REJECTED (it's LABEL): %+v", vv)
|
|
}
|
|
}
|
|
if !sawLabel {
|
|
t.Errorf("ScanRepo missed CheckAdHocSubtype label trigger; got %+v", v)
|
|
}
|
|
}
|