mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +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.
168 lines
5.8 KiB
Go
168 lines
5.8 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
larkauth "github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/zalando/go-keyring"
|
|
)
|
|
|
|
// `lark-cli auth check` is a predicate command: its README contract is
|
|
// `exit 0 = ok, 1 = missing`. The JSON answer goes to stdout; stderr stays
|
|
// empty so callers can write `if lark-cli auth check ...; then ... fi`
|
|
// without their logs getting polluted by an error envelope on the negative
|
|
// branch. These tests pin that contract end-to-end through the dispatcher.
|
|
|
|
func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
|
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
// UserOpenId left empty: triggers the not_logged_in branch.
|
|
})
|
|
|
|
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
|
|
|
|
if got := output.ExitCodeOf(err); got != 1 {
|
|
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
|
|
}
|
|
var bare *output.ExitError
|
|
if !errors.As(err, &bare) {
|
|
t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err)
|
|
}
|
|
if bare.Detail != nil {
|
|
t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail)
|
|
}
|
|
|
|
if stderr.Len() != 0 {
|
|
t.Errorf("stderr must stay empty for predicate negative answer, got:\n%s", stderr.String())
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
|
}
|
|
if payload["ok"] != false {
|
|
t.Errorf("stdout.ok = %v, want false", payload["ok"])
|
|
}
|
|
if payload["error"] != "not_logged_in" {
|
|
t.Errorf("stdout.error = %v, want 'not_logged_in'", payload["error"])
|
|
}
|
|
}
|
|
|
|
func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) {
|
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
UserOpenId: "ou_user", UserName: "tester",
|
|
})
|
|
|
|
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
|
|
|
|
if got := output.ExitCodeOf(err); got != 1 {
|
|
t.Errorf("exit code = %d, want 1", got)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Errorf("stderr must stay empty, got:\n%s", stderr.String())
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("stdout must be valid JSON: %v", err)
|
|
}
|
|
if payload["ok"] != false {
|
|
t.Errorf("stdout.ok = %v, want false", payload["ok"])
|
|
}
|
|
if payload["error"] != "no_token" {
|
|
t.Errorf("stdout.error = %v, want 'no_token'", payload["error"])
|
|
}
|
|
}
|
|
|
|
func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
|
|
// Predicate command happy path: stored token covers every required
|
|
// scope. Exit must be 0 (nil error, not ErrBare), stdout carries the
|
|
// `{"ok":true,...}` JSON answer, and stderr stays empty so shell
|
|
// callers can rely on `if lark-cli auth check ...; then` without log
|
|
// pollution. Pairs with the two exit-1 negatives above so both
|
|
// branches of the predicate contract are pinned.
|
|
keyring.MockInit()
|
|
t.Setenv("HOME", t.TempDir())
|
|
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
|
|
|
cfg := &core.CliConfig{
|
|
AppID: "test-app",
|
|
AppSecret: "test-secret",
|
|
Brand: core.BrandFeishu,
|
|
UserOpenId: "ou_user",
|
|
UserName: "tester",
|
|
}
|
|
now := time.Now()
|
|
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
|
AppId: cfg.AppID,
|
|
UserOpenId: cfg.UserOpenId,
|
|
AccessToken: "user-access-token",
|
|
RefreshToken: "refresh-token",
|
|
ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
|
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
|
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
|
Scope: "im:message docx:document",
|
|
}); err != nil {
|
|
t.Fatalf("SetStoredToken() error = %v", err)
|
|
}
|
|
|
|
f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
|
|
|
|
err := authCheckRun(&CheckOptions{Factory: f, Scope: "im:message"})
|
|
|
|
if err != nil {
|
|
t.Fatalf("expected nil error for happy path (exit 0), got %v", err)
|
|
}
|
|
if got := output.ExitCodeOf(err); got != 0 {
|
|
t.Errorf("exit code = %d, want 0", got)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Errorf("stderr must stay empty for predicate exit-0 answer, got:\n%s", stderr.String())
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
|
}
|
|
if payload["ok"] != true {
|
|
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
|
}
|
|
granted, ok := payload["granted"].([]any)
|
|
if !ok || len(granted) != 1 || granted[0] != "im:message" {
|
|
t.Errorf("stdout.granted = %v, want [im:message]", payload["granted"])
|
|
}
|
|
if payload["missing"] != nil {
|
|
t.Errorf("stdout.missing = %v, want nil/absent on happy path", payload["missing"])
|
|
}
|
|
if _, has := payload["suggestion"]; has {
|
|
t.Errorf("stdout.suggestion must be absent on happy path; got %v", payload["suggestion"])
|
|
}
|
|
}
|
|
|
|
func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
|
|
// Scope validation is a real input error, not a predicate negative
|
|
// answer — it must surface as a typed ValidationError with the normal
|
|
// stderr envelope, distinct from the silent ErrBare predicate path.
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
err := authCheckRun(&CheckOptions{Factory: f, Scope: " "})
|
|
if err == nil {
|
|
t.Fatal("expected validation error for empty --scope")
|
|
}
|
|
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
|
|
}
|
|
}
|