mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
0f35676a28
* feat(drive): support Miaoda apps in permission shortcuts Extend Drive permission shortcuts to accept Miaoda page URLs and the apps resource type while keeping each endpoint's accepted resource contract explicit. Key features: - Infer apps from /page/ URLs and accept explicit --type=apps in +apply-permission, +member-add, +member-list, and +permission-get-setting - Decouple secure-label target parsing so expanding apply-permission does not widen secure-label support - Align skill guidance and unit/dry-run coverage with the new resource type * test(drive): cover apps permission target validation Add focused coverage for Miaoda apps target handling across apply-permission and secure-label boundaries. Exercise malformed page URLs, explicit apps bare tokens, typed validation errors, and command-level rejection so future resource-type changes cannot silently widen unsupported secure-label behavior. * fix(drive): parse permission markers from URL paths Keep drive +apply-permission resource inference aligned with URL component boundaries. Parse and validate URL inputs before extracting tokens so query strings and fragments cannot redirect permission requests to a different resource. Key fixes: - Match document and apps markers only against the parsed URL path - Reject malformed URLs with a typed --token validation error - Cover /page/ markers found only in query strings or fragments * docs(skills): redact Miaoda page token example Replace the concrete Miaoda page token with a representative pagcn placeholder. This keeps the token shape recognizable while avoiding exposure of a real resource identifier in the skill documentation. * fix(drive): harden permission target resolution Make Drive shortcut targets unambiguous before they reach read or write API paths. URL inputs now bind to a recognized root path and a single validated token segment, preventing encoded separators, dot segments, and type conflicts from silently changing the addressed resource. Key fixes: - Reject non-root URLs, dot/traversal tokens, and URL/type conflicts for secure-label and permission-apply writes - Keep permission-setting URL parsing and pretty output reversible for every supported command-local resource kind - Add unit and dry-run E2E regressions plus aligned permission-apply guidance
586 lines
16 KiB
Go
586 lines
16 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package drive
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
func newDrivePermissionGetSettingRuntime(t *testing.T, token, docType string) *common.RuntimeContext {
|
|
t.Helper()
|
|
|
|
cmd := &cobra.Command{Use: "drive +permission-get-setting"}
|
|
cmd.Flags().String("token", "", "")
|
|
cmd.Flags().String("type", "", "")
|
|
if token != "" {
|
|
if err := cmd.Flags().Set("token", token); err != nil {
|
|
t.Fatalf("set --token: %v", err)
|
|
}
|
|
}
|
|
if docType != "" {
|
|
if err := cmd.Flags().Set("type", docType); err != nil {
|
|
t.Fatalf("set --type: %v", err)
|
|
}
|
|
}
|
|
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
|
}
|
|
|
|
func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
docType string
|
|
wantTok string
|
|
wantType string
|
|
}{
|
|
{
|
|
name: "folder URL",
|
|
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
|
wantTok: "fldTok",
|
|
wantType: "folder",
|
|
},
|
|
{
|
|
name: "docx URL",
|
|
token: "https://example.feishu.cn/docx/doxTok",
|
|
wantTok: "doxTok",
|
|
wantType: "docx",
|
|
},
|
|
{
|
|
name: "file URL",
|
|
token: "https://example.feishu.cn/file/boxTok",
|
|
wantTok: "boxTok",
|
|
wantType: "file",
|
|
},
|
|
{
|
|
name: "wiki URL",
|
|
token: "https://example.feishu.cn/wiki/wikTok",
|
|
wantTok: "wikTok",
|
|
wantType: "wiki",
|
|
},
|
|
{
|
|
name: "minutes URL",
|
|
token: "https://example.feishu.cn/minutes/obTok",
|
|
wantTok: "obTok",
|
|
wantType: "minutes",
|
|
},
|
|
{
|
|
name: "mindnotes URL",
|
|
token: "https://example.feishu.cn/mindnotes/mndTok",
|
|
wantTok: "mndTok",
|
|
wantType: "mindnote",
|
|
},
|
|
{
|
|
name: "canonical mindnote URL",
|
|
token: "https://example.feishu.cn/mindnote/mndTok",
|
|
wantTok: "mndTok",
|
|
wantType: "mindnote",
|
|
},
|
|
{
|
|
name: "bare folder token",
|
|
token: " fldTok ",
|
|
docType: " folder ",
|
|
wantTok: "fldTok",
|
|
wantType: "folder",
|
|
},
|
|
{
|
|
name: "bare file token",
|
|
token: "boxTok",
|
|
docType: "file",
|
|
wantTok: "boxTok",
|
|
wantType: "file",
|
|
},
|
|
{
|
|
name: "bare wiki token",
|
|
token: "wikTok",
|
|
docType: "wiki",
|
|
wantTok: "wikTok",
|
|
wantType: "wiki",
|
|
},
|
|
{
|
|
name: "apps page URL",
|
|
token: "https://example.feishu.cn/page/appMetaTok/?from=share",
|
|
wantTok: "appMetaTok",
|
|
wantType: "apps",
|
|
},
|
|
{
|
|
name: "bare token with explicit apps type",
|
|
token: "appBareMetaTok",
|
|
docType: "apps",
|
|
wantTok: "appBareMetaTok",
|
|
wantType: "apps",
|
|
},
|
|
}
|
|
|
|
for _, temp := range tests {
|
|
tt := temp
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
|
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
|
if err != nil {
|
|
t.Fatalf("read spec: %v", err)
|
|
}
|
|
if spec.Token != tt.wantTok {
|
|
t.Fatalf("Token = %q, want %q", spec.Token, tt.wantTok)
|
|
}
|
|
if spec.Type != tt.wantType {
|
|
t.Fatalf("Type = %q, want %q", spec.Type, tt.wantType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDrivePermissionGetSettingResourceKindsRoundTrip(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const token = "resourceRoundTripTok"
|
|
for _, resourceKind := range drivePermissionGetSettingResourceKinds {
|
|
kind := resourceKind
|
|
t.Run(kind.Type, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bareRuntime := newDrivePermissionGetSettingRuntime(t, token, kind.Type)
|
|
bareSpec, err := readDrivePermissionGetSettingSpec(bareRuntime)
|
|
if err != nil {
|
|
t.Fatalf("read bare-token spec: %v", err)
|
|
}
|
|
resourceURL := bareSpec.url(bareRuntime)
|
|
if resourceURL == "" {
|
|
t.Fatalf("resource URL is empty for allowed type %q", kind.Type)
|
|
}
|
|
|
|
urlRuntime := newDrivePermissionGetSettingRuntime(t, resourceURL, "")
|
|
urlSpec, err := readDrivePermissionGetSettingSpec(urlRuntime)
|
|
if err != nil {
|
|
t.Fatalf("read generated URL spec %q: %v", resourceURL, err)
|
|
}
|
|
if urlSpec.Token != token || urlSpec.Type != kind.Type {
|
|
t.Fatalf(
|
|
"generated URL resolved to token/type %q/%q, want %q/%q",
|
|
urlSpec.Token,
|
|
urlSpec.Type,
|
|
token,
|
|
kind.Type,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDrivePermissionGetSettingResourceURLUsesConfiguredBrand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runtime := newDrivePermissionGetSettingRuntime(t, "appMetaTok", "apps")
|
|
runtime.Config.Brand = core.BrandLark
|
|
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
|
if err != nil {
|
|
t.Fatalf("read spec: %v", err)
|
|
}
|
|
if got, want := spec.url(runtime), "https://www.larksuite.com/page/appMetaTok"; got != want {
|
|
t.Fatalf("resource URL = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
docType string
|
|
wantParam string
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "missing token",
|
|
wantParam: "--token",
|
|
wantMessage: "--token is required",
|
|
},
|
|
{
|
|
name: "bare token without type",
|
|
token: "doxTok",
|
|
wantParam: "--type",
|
|
wantMessage: "--type is required",
|
|
},
|
|
{
|
|
name: "bare token contains path separator",
|
|
token: "doxTok/other",
|
|
docType: "docx",
|
|
wantParam: "--token",
|
|
wantMessage: "single path segment",
|
|
},
|
|
{
|
|
name: "bare dot token",
|
|
token: ".",
|
|
docType: "docx",
|
|
wantParam: "--token",
|
|
wantMessage: "non-dot single path segment",
|
|
},
|
|
{
|
|
name: "non-HTTP URL",
|
|
token: "ftp://example.feishu.cn/docx/doxTok",
|
|
wantParam: "--token",
|
|
wantMessage: "unsupported --token URL",
|
|
},
|
|
{
|
|
name: "unsupported URL",
|
|
token: "https://example.feishu.cn/calendar/calTok",
|
|
wantParam: "--token",
|
|
wantMessage: "unsupported --token URL",
|
|
},
|
|
{
|
|
name: "URL type conflict",
|
|
token: "https://example.feishu.cn/docx/doxTok",
|
|
docType: "sheet",
|
|
wantParam: "--type",
|
|
wantMessage: "conflicts with URL path type",
|
|
},
|
|
{
|
|
name: "invalid bare token",
|
|
token: "../bad",
|
|
docType: "folder",
|
|
wantParam: "--token",
|
|
wantMessage: "--token",
|
|
},
|
|
{
|
|
name: "invalid type",
|
|
token: "doxTok",
|
|
docType: "comment",
|
|
wantParam: "--type",
|
|
wantMessage: "invalid --type",
|
|
},
|
|
}
|
|
|
|
for _, temp := range tests {
|
|
tt := temp
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
|
_, err := readDrivePermissionGetSettingSpec(runtime)
|
|
if err == nil {
|
|
t.Fatal("expected validation error, got nil")
|
|
}
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("error is not typed: %T %v", err, err)
|
|
}
|
|
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
|
}
|
|
if validationErr, ok := err.(*errs.ValidationError); ok {
|
|
if validationErr.Param != tt.wantParam {
|
|
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
|
}
|
|
} else {
|
|
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantMessage) {
|
|
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDrivePermissionGetSettingDryRunIncludesGETRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
docType string
|
|
wantURL string
|
|
wantType string
|
|
}{
|
|
{
|
|
name: "folder URL",
|
|
token: "https://example.feishu.cn/drive/folder/fldTok",
|
|
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
|
wantType: "folder",
|
|
},
|
|
{
|
|
name: "bare folder token",
|
|
token: "fldTok",
|
|
docType: "folder",
|
|
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
|
wantType: "folder",
|
|
},
|
|
{
|
|
name: "docx URL",
|
|
token: "https://example.feishu.cn/docx/doxTok",
|
|
wantURL: "/open-apis/drive/v2/permissions/doxTok/public",
|
|
wantType: "docx",
|
|
},
|
|
{
|
|
name: "bare wiki token",
|
|
token: "wikTok",
|
|
docType: "wiki",
|
|
wantURL: "/open-apis/drive/v2/permissions/wikTok/public",
|
|
wantType: "wiki",
|
|
},
|
|
{
|
|
name: "file URL",
|
|
token: "https://example.feishu.cn/file/boxTok",
|
|
wantURL: "/open-apis/drive/v2/permissions/boxTok/public",
|
|
wantType: "file",
|
|
},
|
|
{
|
|
name: "minutes URL",
|
|
token: "https://example.feishu.cn/minutes/obTok",
|
|
wantURL: "/open-apis/drive/v2/permissions/obTok/public",
|
|
wantType: "minutes",
|
|
},
|
|
{
|
|
name: "mindnotes URL",
|
|
token: "https://example.feishu.cn/mindnotes/mndTok",
|
|
wantURL: "/open-apis/drive/v2/permissions/mndTok/public",
|
|
wantType: "mindnote",
|
|
},
|
|
}
|
|
|
|
for _, temp := range tests {
|
|
tt := temp
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
|
dry := DrivePermissionGetSetting.DryRun(context.Background(), runtime)
|
|
if dry == nil {
|
|
t.Fatal("DryRun returned nil")
|
|
}
|
|
data, err := json.Marshal(dry)
|
|
if err != nil {
|
|
t.Fatalf("marshal dry-run: %v", err)
|
|
}
|
|
out := string(data)
|
|
for _, want := range []string{
|
|
`"` + tt.wantURL + `"`,
|
|
`"GET"`,
|
|
`"type":"` + tt.wantType + `"`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
if strings.Contains(out, `"folder_token"`) {
|
|
t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDrivePermissionGetSettingExecutePreservesPermissionPublic(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"permission_public": map[string]interface{}{
|
|
"link_share_entity": "closed",
|
|
"external_access_entity": "closed",
|
|
"security_entity": "anyone_can_view",
|
|
"comment_entity": "anyone_can_view",
|
|
"share_entity": "anyone",
|
|
"manage_collaborator_entity": "collaborator_can_view",
|
|
"lock_switch": false,
|
|
"server_future_field": "preserved",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
|
"+permission-get-setting",
|
|
"--token", "doxTok",
|
|
"--type", "docx",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
data := decodeDriveEnvelope(t, stdout)
|
|
for _, key := range []string{"type", "token", "url"} {
|
|
if _, ok := data[key]; ok {
|
|
t.Fatalf("data[%s] = %#v, want field omitted", key, data[key])
|
|
}
|
|
}
|
|
permissionPublic, _ := data["permission_public"].(map[string]interface{})
|
|
if permissionPublic == nil {
|
|
t.Fatalf("permission_public missing in output: %#v", data)
|
|
}
|
|
for key, want := range map[string]interface{}{
|
|
"link_share_entity": "closed",
|
|
"external_access_entity": "closed",
|
|
"security_entity": "anyone_can_view",
|
|
"comment_entity": "anyone_can_view",
|
|
"share_entity": "anyone",
|
|
"manage_collaborator_entity": "collaborator_can_view",
|
|
"lock_switch": false,
|
|
"server_future_field": "preserved",
|
|
} {
|
|
if permissionPublic[key] != want {
|
|
t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDrivePermissionGetSettingExecuteRejectsMissingPermissionPublic(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{"unexpected": "response"},
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
|
"+permission-get-setting",
|
|
"--token", "doxTok",
|
|
"--type", "docx",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected invalid response error, got nil")
|
|
}
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Fatalf("problem = %#v, want internal/invalid_response", problem)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("stdout should be empty on invalid response, got %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"permission_public": map[string]interface{}{
|
|
"link_share_entity": "closed",
|
|
"server_future_field": "preserved",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
|
"+permission-get-setting",
|
|
"--token", "doxTok",
|
|
"--type", "docx",
|
|
"--format", "pretty",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
for _, want := range []string{
|
|
"Permission settings:",
|
|
`"link_share_entity": "closed"`,
|
|
`"server_future_field": "preserved"`,
|
|
} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("pretty output missing %q:\n%s", want, stdout.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesResourceURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
docType string
|
|
wantURL string
|
|
}{
|
|
{
|
|
name: "apps",
|
|
token: "appMetaTok",
|
|
docType: "apps",
|
|
wantURL: "https://www.feishu.cn/page/appMetaTok",
|
|
},
|
|
{
|
|
name: "minutes",
|
|
token: "obcnMinuteTok",
|
|
docType: "minutes",
|
|
wantURL: "https://www.feishu.cn/minutes/obcnMinuteTok",
|
|
},
|
|
}
|
|
|
|
for _, temp := range tests {
|
|
tt := temp
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/drive/v2/permissions/" + tt.token + "/public?type=" + tt.docType,
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"permission_public": map[string]interface{}{
|
|
"link_share_entity": "closed",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
|
"+permission-get-setting",
|
|
"--token", tt.token,
|
|
"--type", tt.docType,
|
|
"--format", "pretty",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "URL: "+tt.wantURL) {
|
|
t.Fatalf("pretty output missing resource URL %q:\n%s", tt.wantURL, stdout.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if !reflect.DeepEqual(DrivePermissionGetSetting.Scopes, []string{"docs:permission.setting:read"}) {
|
|
t.Fatalf("Scopes = %v, want docs:permission.setting:read", DrivePermissionGetSetting.Scopes)
|
|
}
|
|
if !reflect.DeepEqual(DrivePermissionGetSetting.AuthTypes, []string{"user", "bot"}) {
|
|
t.Fatalf("AuthTypes = %v, want [user bot]", DrivePermissionGetSetting.AuthTypes)
|
|
}
|
|
for _, flag := range DrivePermissionGetSetting.Flags {
|
|
if flag.Name == "token" && !flag.Required {
|
|
t.Fatal("--token must be declared required")
|
|
}
|
|
}
|
|
}
|