mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 02:14:02 +08:00
When AutoGrantCurrentUserDrivePermission encounters lark code 99991672/99991679, extract permission_violations from the underlying ExitError and surface lark_code, required_scope, and console_url on the result map. Override the generic fallback hint with one pointing at the developer console — the concrete next step a user can take. Refactor extractRequiredScopes / SelectRecommendedScope wrapping / console URL construction out of cmd/root.go into internal/registry/scope_hint.go so both the top-level enrichPermissionError path and the best-effort sub-call path in shortcuts/common share one implementation. Change-Id: Ida63ed160d1167b7961b6faac5c2cf9b7f971c65
312 lines
11 KiB
Go
312 lines
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package common
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) {
|
|
config := &core.CliConfig{
|
|
AppID: "perm-grant-test-skip",
|
|
AppSecret: "perm-grant-test-secret-skip",
|
|
Brand: core.BrandFeishu,
|
|
}
|
|
f, _, stderr, _ := cmdutil.TestFactory(t, config)
|
|
|
|
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-1")
|
|
runtime := &RuntimeContext{
|
|
ctx: ctx,
|
|
Config: config,
|
|
Factory: f,
|
|
resolvedAs: core.AsBot,
|
|
}
|
|
|
|
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result for bot mode with empty user open_id")
|
|
}
|
|
if result["status"] != PermissionGrantSkipped {
|
|
t.Fatalf("status = %v, want %q", result["status"], PermissionGrantSkipped)
|
|
}
|
|
if !strings.Contains(stderr.String(), "auto-grant was skipped") {
|
|
t.Fatalf("stderr missing auto-grant skipped warning; got:\n%s", stderr.String())
|
|
}
|
|
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "auth login") {
|
|
t.Fatalf("hint = %#v, want string containing 'auth login'", result["hint"])
|
|
}
|
|
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "not logged in") {
|
|
t.Fatalf("hint = %#v, want string containing 'not logged in'", result["hint"])
|
|
}
|
|
}
|
|
|
|
func TestAutoGrantStderrWarning_GrantFailed(t *testing.T) {
|
|
config := &core.CliConfig{
|
|
AppID: "perm-grant-test-fail",
|
|
AppSecret: "perm-grant-test-secret-fail",
|
|
Brand: core.BrandFeishu,
|
|
UserOpenId: "ou_test_user",
|
|
}
|
|
f, _, stderr, reg := cmdutil.TestFactory(t, config)
|
|
|
|
// Register a stub that returns an error code so CallAPI returns an error.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/permissions/tkn_doc/members",
|
|
Body: map[string]interface{}{
|
|
"code": 230001,
|
|
"msg": "no permission",
|
|
},
|
|
})
|
|
|
|
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-2")
|
|
runtime := &RuntimeContext{
|
|
ctx: ctx,
|
|
Config: config,
|
|
Factory: f,
|
|
resolvedAs: core.AsBot,
|
|
}
|
|
|
|
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result for bot mode with grant failure")
|
|
}
|
|
if result["status"] != PermissionGrantFailed {
|
|
t.Fatalf("status = %v, want %q", result["status"], PermissionGrantFailed)
|
|
}
|
|
if !strings.Contains(stderr.String(), "auto-grant failed") {
|
|
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())
|
|
}
|
|
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "Retry later") {
|
|
t.Fatalf("hint = %#v, want string containing 'Retry later'", result["hint"])
|
|
}
|
|
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "scope") {
|
|
t.Fatalf("hint = %#v, want string containing 'scope'", result["hint"])
|
|
}
|
|
if hint, ok := result["hint"].(string); !ok || !strings.Contains(hint, "permission changes") {
|
|
t.Fatalf("hint = %#v, want string containing 'permission changes'", result["hint"])
|
|
}
|
|
}
|
|
|
|
// ── annotateGrantPermissionError unit tests ────────────────────────────────
|
|
|
|
func newAnnotateRuntime(brand core.LarkBrand, appID string) *RuntimeContext {
|
|
return &RuntimeContext{
|
|
Config: &core.CliConfig{
|
|
AppID: appID,
|
|
Brand: brand,
|
|
},
|
|
}
|
|
}
|
|
|
|
// permission_violations subjects must surface as required_scope, and the
|
|
// console_url must be brand-specific. The hint should be overridden to point
|
|
// at the developer console.
|
|
func TestAnnotateGrantPermissionError_AppScopeNotEnabled(t *testing.T) {
|
|
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
|
|
result := map[string]interface{}{
|
|
"hint": "generic fallback hint",
|
|
}
|
|
|
|
err := output.ErrAPI(99991672, "Permission denied [99991672]", map[string]interface{}{
|
|
"permission_violations": []interface{}{
|
|
map[string]interface{}{"subject": "docs:permission.member:create"},
|
|
},
|
|
})
|
|
|
|
annotateGrantPermissionError(rt, result, err)
|
|
|
|
if got := result["lark_code"]; got != 99991672 {
|
|
t.Errorf("expected lark_code=99991672, got %v", got)
|
|
}
|
|
if got, _ := result["required_scope"].(string); got != "docs:permission.member:create" {
|
|
t.Errorf("required_scope mismatch: got %v", got)
|
|
}
|
|
consoleURL, _ := result["console_url"].(string)
|
|
if !strings.HasPrefix(consoleURL, "https://open.feishu.cn/page/scope-apply") {
|
|
t.Errorf("console_url should target open.feishu.cn, got %s", consoleURL)
|
|
}
|
|
if !strings.Contains(consoleURL, "clientID=cli_demo") {
|
|
t.Errorf("console_url missing clientID, got %s", consoleURL)
|
|
}
|
|
hint, _ := result["hint"].(string)
|
|
if !strings.Contains(hint, "console_url") {
|
|
t.Errorf("hint should reference console_url, got %s", hint)
|
|
}
|
|
if !strings.Contains(hint, "docs:permission.member:create") {
|
|
t.Errorf("hint should mention required scope, got %s", hint)
|
|
}
|
|
}
|
|
|
|
func TestAnnotateGrantPermissionError_LarkBrand(t *testing.T) {
|
|
rt := newAnnotateRuntime(core.BrandLark, "cli_demo")
|
|
result := map[string]interface{}{}
|
|
err := output.ErrAPI(99991679, "Permission denied [99991679]", map[string]interface{}{
|
|
"permission_violations": []interface{}{
|
|
map[string]interface{}{"subject": "docs:permission.member:create"},
|
|
},
|
|
})
|
|
|
|
annotateGrantPermissionError(rt, result, err)
|
|
|
|
if u, _ := result["console_url"].(string); !strings.Contains(u, "open.larksuite.com") {
|
|
t.Errorf("lark brand should yield larksuite host, got %s", u)
|
|
}
|
|
}
|
|
|
|
// Non-permission errors (network, validation, plain errors) must not be
|
|
// annotated — keep the existing generic hint untouched.
|
|
func TestAnnotateGrantPermissionError_NonPermissionErrorNoOp(t *testing.T) {
|
|
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
|
|
|
|
cases := []error{
|
|
errors.New("plain error"),
|
|
output.ErrNetwork("connection reset"),
|
|
output.ErrValidation("bad request"),
|
|
// Non-permission API errors (e.g. 230001) — type is "api_error" not "permission"
|
|
output.ErrAPI(230001, "no permission", map[string]interface{}{
|
|
"permission_violations": []interface{}{
|
|
map[string]interface{}{"subject": "docs:doc"},
|
|
},
|
|
}),
|
|
}
|
|
for i, e := range cases {
|
|
result := map[string]interface{}{
|
|
"hint": "untouched hint",
|
|
}
|
|
annotateGrantPermissionError(rt, result, e)
|
|
if _, ok := result["lark_code"]; ok {
|
|
t.Errorf("case %d: expected no lark_code, got %v", i, result["lark_code"])
|
|
}
|
|
if _, ok := result["console_url"]; ok {
|
|
t.Errorf("case %d: expected no console_url, got %v", i, result["console_url"])
|
|
}
|
|
if got, _ := result["hint"].(string); got != "untouched hint" {
|
|
t.Errorf("case %d: hint should be unchanged, got %s", i, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// permission_violations missing → only lark_code is annotated; no console_url
|
|
// and the existing hint stays as-is (caller's generic fallback wins).
|
|
func TestAnnotateGrantPermissionError_NoViolations(t *testing.T) {
|
|
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
|
|
result := map[string]interface{}{
|
|
"hint": "untouched fallback",
|
|
}
|
|
err := output.ErrAPI(99991672, "Permission denied [99991672]", nil)
|
|
|
|
annotateGrantPermissionError(rt, result, err)
|
|
|
|
if got := result["lark_code"]; got != 99991672 {
|
|
t.Errorf("expected lark_code captured, got %v", got)
|
|
}
|
|
if _, ok := result["console_url"]; ok {
|
|
t.Errorf("console_url must not be set when violations are absent")
|
|
}
|
|
if got, _ := result["hint"].(string); got != "untouched fallback" {
|
|
t.Errorf("hint should remain fallback when no console_url, got %s", got)
|
|
}
|
|
}
|
|
|
|
// AppID empty → no console_url even when violations exist.
|
|
func TestAnnotateGrantPermissionError_EmptyAppID(t *testing.T) {
|
|
rt := newAnnotateRuntime(core.BrandFeishu, "")
|
|
result := map[string]interface{}{}
|
|
err := output.ErrAPI(99991672, "Permission denied", map[string]interface{}{
|
|
"permission_violations": []interface{}{
|
|
map[string]interface{}{"subject": "docs:doc"},
|
|
},
|
|
})
|
|
|
|
annotateGrantPermissionError(rt, result, err)
|
|
if _, ok := result["console_url"]; ok {
|
|
t.Errorf("console_url must not be set when appID is empty")
|
|
}
|
|
if got, _ := result["required_scope"].(string); got != "docs:doc" {
|
|
t.Errorf("required_scope should still be set when appID is empty, got %s", got)
|
|
}
|
|
}
|
|
|
|
// Defensive: nil/empty arguments must be safe no-ops.
|
|
func TestAnnotateGrantPermissionError_NilArgsSafe(t *testing.T) {
|
|
rt := newAnnotateRuntime(core.BrandFeishu, "cli_demo")
|
|
|
|
annotateGrantPermissionError(nil, map[string]interface{}{}, nil)
|
|
annotateGrantPermissionError(rt, nil, nil)
|
|
annotateGrantPermissionError(rt, map[string]interface{}{}, nil)
|
|
annotateGrantPermissionError(rt, map[string]interface{}{}, errors.New(""))
|
|
}
|
|
|
|
// Integration-style: end-to-end through AutoGrantCurrentUserDrivePermission
|
|
// with a mocked 99991672 response — verifies the annotated fields show up
|
|
// in the JSON result that callers downstream consume.
|
|
func TestAutoGrantStderrWarning_GrantFailed_AppScopeNotEnabled_Annotated(t *testing.T) {
|
|
config := &core.CliConfig{
|
|
AppID: "cli_app_demo",
|
|
AppSecret: "secret",
|
|
Brand: core.BrandFeishu,
|
|
UserOpenId: "ou_test_user",
|
|
}
|
|
f, _, _, reg := cmdutil.TestFactory(t, config)
|
|
|
|
// Stub the permission member create endpoint with a 99991672 response that
|
|
// includes permission_violations — what the platform returns when the app
|
|
// has not enabled the API scope.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/permissions/tkn_doc/members",
|
|
Body: map[string]interface{}{
|
|
"code": 99991672,
|
|
"msg": "App scope not enabled",
|
|
"error": map[string]interface{}{
|
|
"permission_violations": []interface{}{
|
|
map[string]interface{}{"subject": "docs:permission.member:create"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
ctx := cmdutil.ContextWithShortcut(context.Background(), "test:shortcut", "exec-3")
|
|
runtime := &RuntimeContext{
|
|
ctx: ctx,
|
|
Config: config,
|
|
Factory: f,
|
|
resolvedAs: core.AsBot,
|
|
}
|
|
|
|
result := AutoGrantCurrentUserDrivePermission(runtime, "tkn_doc", "docx")
|
|
if result == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if result["status"] != PermissionGrantFailed {
|
|
t.Fatalf("status = %v, want failed", result["status"])
|
|
}
|
|
if result["lark_code"] != 99991672 {
|
|
t.Errorf("lark_code = %v, want 99991672", result["lark_code"])
|
|
}
|
|
if got, _ := result["required_scope"].(string); got != "docs:permission.member:create" {
|
|
t.Errorf("required_scope = %v, want docs:permission.member:create", got)
|
|
}
|
|
consoleURL, _ := result["console_url"].(string)
|
|
if !strings.Contains(consoleURL, "open.feishu.cn/page/scope-apply") {
|
|
t.Errorf("console_url missing or wrong host: %s", consoleURL)
|
|
}
|
|
if !strings.Contains(consoleURL, "scopes=docs%3Apermission.member%3Acreate") {
|
|
t.Errorf("console_url missing escaped scope: %s", consoleURL)
|
|
}
|
|
hint, _ := result["hint"].(string)
|
|
if !strings.Contains(hint, "console_url") {
|
|
t.Errorf("hint should be overridden to mention console_url, got %s", hint)
|
|
}
|
|
}
|