diff --git a/shortcuts/base/base_resolve.go b/shortcuts/base/base_resolve.go index 31de0dbec6..73be53b7aa 100644 --- a/shortcuts/base/base_resolve.go +++ b/shortcuts/base/base_resolve.go @@ -195,7 +195,9 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error { switch classifyBaseURL(parsed) { case "base_url": out := resolveBaseURL(parsed) - enrichBaseResolveHint(runtime, out, resolveBaseURLSelection(parsed)) + if err := enrichBaseResolveHint(runtime, out, resolveBaseURLSelection(parsed)); err != nil { + return err + } runtime.OutFormat(out, nil, nil) return nil case "wiki_url": @@ -205,7 +207,9 @@ func executeBaseURLResolve(runtime *common.RuntimeContext) error { } selection := resolveBaseURLSelection(parsed) applyBaseURLSelection(out, selection) - enrichBaseResolveHint(runtime, out, selection) + if err := enrichBaseResolveHint(runtime, out, selection); err != nil { + return err + } runtime.OutFormat(out, nil, nil) return nil case "record_share_url": @@ -422,15 +426,19 @@ func executeBaseTitleResolve(runtime *common.RuntimeContext) error { } } -func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}, selection baseURLSelection) { +func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}, selection baseURLSelection) error { baseToken := strings.TrimSpace(common.GetString(out, "base_token")) selectedBlockID := strings.TrimSpace(common.GetString(out, "block_id")) if baseToken == "" || selectedBlockID == "" { out["hint"] = resolveHint("", nil) - return + return nil } - if block, found, err := resolveSelectedBaseBlock(runtime, baseToken, selectedBlockID); err == nil && found { + block, found, err := resolveSelectedBaseBlock(runtime, baseToken, selectedBlockID) + if err != nil { + return err + } + if found { out["block_type"] = block.Type if block.Name != "" { out["block_name"] = block.Name @@ -467,10 +475,11 @@ func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interf default: out["hint"] = resolveUnknownBlockHint() } - return + return nil } out["hint"] = resolveUnknownBlockHint() + return nil } type resolvedBaseBlock struct { diff --git a/shortcuts/base/base_resolve_test.go b/shortcuts/base/base_resolve_test.go index c1579e9c2a..5cfa363367 100644 --- a/shortcuts/base/base_resolve_test.go +++ b/shortcuts/base/base_resolve_test.go @@ -4,6 +4,7 @@ package base import ( + "errors" "net/http" "strings" "testing" @@ -67,7 +68,10 @@ func TestBaseURLResolveBaseURL(t *testing.T) { }) t.Run("unconfirmed selected block stays neutral", func(t *testing.T) { - factory, stdout, _ := newExecuteFactory(t) + factory, stdout, reg := newExecuteFactory(t) + reg.Register(baseBlockListResolveStub("bas123", + map[string]interface{}{"id": "tbl_other", "type": "table", "name": "Other"}, + )) err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ "+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale&record=rec_stale", "--as", "user", }, factory, stdout) @@ -96,6 +100,15 @@ func TestBaseURLResolveBaseURL(t *testing.T) { } }) + t.Run("block list error is returned", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(baseBlockListScopeErrorStub("bas123")) + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew_stale", "--as", "bot", + }, factory, stdout) + assertBaseBlockReadPermissionError(t, err, stdout) + }) + t.Run("field endpoint does not confirm untyped block", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(baseBlockListResolveStub("bas123", @@ -269,6 +282,22 @@ func baseBlockListResolveStub(baseToken string, blocks ...map[string]interface{} } } +func baseBlockListScopeErrorStub(baseToken string) *httpmock.Stub { + return &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/" + baseToken + "/blocks/list", + Body: map[string]interface{}{ + "code": 99991672, + "msg": "access denied", + "error": map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "base:block:read"}, + }, + }, + }, + } +} + func TestBaseURLResolveWikiURL(t *testing.T) { t.Run("bitable", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) @@ -309,6 +338,19 @@ func TestBaseURLResolveWikiURL(t *testing.T) { } }) + t.Run("bitable table coordinates return block list error", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base")) + reg.Register(baseBlockListScopeErrorStub("bas123")) + + err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{ + "+url-resolve", + "--url", "https://example.larkoffice.com/wiki/wik123?table=tbl123&view=vew_stale", + "--as", "bot", + }, factory, stdout) + assertBaseBlockReadPermissionError(t, err, stdout) + }) + t.Run("bitable with dashboard selection", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(wikiBaseNodeStub("wik123", "bas123", "Demo Base")) @@ -376,6 +418,33 @@ func wikiBaseNodeStub(wikiToken, baseToken, title string) *httpmock.Stub { } } +func assertBaseBlockReadPermissionError(t *testing.T, err error, stdout interface { + Len() int + String() string +}) { + t.Helper() + if err == nil { + t.Fatal("expected block-list error to be returned") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T %v", err, err) + } + if p.Category != errs.CategoryAuthorization || p.Subtype != errs.SubtypeAppScopeNotApplied || p.Code != 99991672 { + t.Fatalf("unexpected problem: %#v", p) + } + var permissionErr *errs.PermissionError + if !errors.As(err, &permissionErr) { + t.Fatalf("expected PermissionError, got %T %v", err, err) + } + if len(permissionErr.MissingScopes) != 1 || permissionErr.MissingScopes[0] != "base:block:read" { + t.Fatalf("missing scopes=%v", permissionErr.MissingScopes) + } + if stdout.Len() != 0 { + t.Fatalf("stdout should stay empty when block-list fails: %s", stdout.String()) + } +} + func TestBaseURLResolveRecordShareURL(t *testing.T) { t.Run("enriched", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t)