From 737045a42620977aabec9b06eef8626eee2231cc Mon Sep 17 00:00:00 2001 From: "jiaxing.04" Date: Mon, 1 Jun 2026 15:36:06 +0800 Subject: [PATCH] feat(drive): add +member-add shortcut for Drive collaborator permissions Add drive +member-add shortcut that grants view/edit/full_access permissions to collaborators on Drive documents, files, folders, and wiki nodes. Supports single and batch member add (up to 10). Key features: - Auto-infer resource type from Feishu/Lark/Doubao URL or token prefix - Required --member-type with prefix conflict detection (appid aliased to userid) - Reject --perm-type for non-wiki resource types - Trusted host validation for URL inputs Also extends shortcuts/common/resource_url with minutes type support and InferResourceTypeFromToken for bare token prefix mapping. --- shortcuts/common/resource_url.go | 42 + shortcuts/common/resource_url_test.go | 44 +- shortcuts/drive/drive_member_add.go | 482 ++++++++++++ shortcuts/drive/drive_member_add_test.go | 734 ++++++++++++++++++ shortcuts/drive/shortcuts.go | 1 + shortcuts/drive/shortcuts_test.go | 1 + skills/lark-drive/SKILL.md | 20 +- .../references/lark-drive-member-add.md | 123 +++ .../drive/drive_member_add_dryrun_test.go | 323 ++++++++ 9 files changed, 1762 insertions(+), 8 deletions(-) create mode 100644 shortcuts/drive/drive_member_add.go create mode 100644 shortcuts/drive/drive_member_add_test.go create mode 100644 skills/lark-drive/references/lark-drive-member-add.md create mode 100644 tests/cli_e2e/drive/drive_member_add_dryrun_test.go diff --git a/shortcuts/common/resource_url.go b/shortcuts/common/resource_url.go index 29ec31c10..1a11db9ff 100644 --- a/shortcuts/common/resource_url.go +++ b/shortcuts/common/resource_url.go @@ -50,6 +50,8 @@ func BuildResourceURL(brand core.LarkBrand, kind, token string) string { return host + "/drive/folder/" + token case "mindnote": return host + "/mindnote/" + token + case "minutes": + return host + "/minutes/" + token case "slides": return host + "/slides/" + token default: @@ -84,6 +86,7 @@ var urlPathToType = []struct { {"/wiki/", "wiki"}, {"/file/", "file"}, {"/mindnote/", "mindnote"}, + {"/minutes/", "minutes"}, {"/slides/", "slides"}, } @@ -100,6 +103,7 @@ var urlPathToType = []struct { // /file/TOKEN -> {Type: "file", Token: TOKEN} // /drive/folder/TOKEN -> {Type: "folder", Token: TOKEN} // /mindnote/TOKEN -> {Type: "mindnote", Token: TOKEN} +// /minutes/TOKEN -> {Type: "minutes", Token: TOKEN} // /slides/TOKEN -> {Type: "slides", Token: TOKEN} // // Returns (ResourceRef{}, false) when the URL does not match any known pattern. @@ -135,3 +139,41 @@ func ParseResourceURL(rawURL string) (ResourceRef, bool) { return ResourceRef{}, false } + +// tokenPrefixToType maps bare Drive token prefixes to resource types. +// The prefixes are stable conventions used across Lark/Feishu tokens. +var tokenPrefixToType = []struct { + Prefix string + Type string +}{ + {"doxcn", "docx"}, + {"doccn", "doc"}, + {"shtcn", "sheet"}, + {"bascn", "bitable"}, + {"fldcn", "folder"}, + {"wikcn", "wiki"}, + {"mncn", "mindnote"}, + {"mincn", "minutes"}, + {"slkcn", "slides"}, + {"boxcn", "file"}, +} + +// InferResourceTypeFromToken maps a bare Drive token prefix to its resource +// type. Returns ("", false) when the prefix is not recognized. +// +// Prefix mapping: doxcn→docx, doccn→doc, shtcn→sheet, bascn→bitable, +// fldcn→folder, wikcn→wiki, mncn→mindnote, mincn→minutes, +// slkcn→slides, boxcn→file. +func InferResourceTypeFromToken(token string) (string, bool) { + token = strings.TrimSpace(token) + if token == "" { + return "", false + } + lower := strings.ToLower(token) + for _, mapping := range tokenPrefixToType { + if strings.HasPrefix(lower, mapping.Prefix) { + return mapping.Type, true + } + } + return "", false +} diff --git a/shortcuts/common/resource_url_test.go b/shortcuts/common/resource_url_test.go index c0109fe9c..4ab2b3a53 100644 --- a/shortcuts/common/resource_url_test.go +++ b/shortcuts/common/resource_url_test.go @@ -32,6 +32,7 @@ func TestParseResourceURL(t *testing.T) { {"folder via /chat/drive/", "https://feishu.doubao.com/chat/drive/fldcnABC", "folder", "fldcnABC", true}, {"folder via /drive/shr/", "https://feishu.doubao.com/drive/shr/fldcnABC", "folder", "fldcnABC", true}, {"mindnote", "https://xxx.feishu.cn/mindnote/mncnABC", "mindnote", "mncnABC", true}, + {"minutes", "https://xxx.feishu.cn/minutes/mincnABC", "minutes", "mincnABC", true}, {"slides", "https://xxx.feishu.cn/slides/slkcnABC", "slides", "slkcnABC", true}, // Lark domain @@ -85,7 +86,7 @@ func TestParseResourceURL(t *testing.T) { func TestParseResourceURL_RoundTrip(t *testing.T) { t.Parallel() - types := []string{"docx", "doc", "sheet", "bitable", "wiki", "file", "folder", "mindnote", "slides"} + types := []string{"docx", "doc", "sheet", "bitable", "wiki", "file", "folder", "mindnote", "minutes", "slides"} token := "testTOKEN123" for _, kind := range types { @@ -108,6 +109,46 @@ func TestParseResourceURL_RoundTrip(t *testing.T) { } } +func TestInferResourceTypeFromToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + wantType string + wantOK bool + }{ + {"docx", "doxcnABC", "docx", true}, + {"doc", "doccnABC", "doc", true}, + {"sheet", "shtcnABC", "sheet", true}, + {"bitable", "bascnABC", "bitable", true}, + {"folder", "fldcnABC", "folder", true}, + {"wiki", "wikcnABC", "wiki", true}, + {"mindnote", "mncnABC", "mindnote", true}, + {"minutes", "mincnABC", "minutes", true}, + {"slides", "slkcnABC", "slides", true}, + {"file", "boxcnABC", "file", true}, + {"case insensitive", "DOXCNabc", "docx", true}, + {"whitespace trimmed", " doxcnABC ", "docx", true}, + {"empty", "", "", false}, + {"whitespace only", " ", "", false}, + {"unrecognized prefix", "abc123", "", false}, + {"ou_ prefix not a resource", "ou_xxx", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotType, gotOK := InferResourceTypeFromToken(tt.token) + if gotOK != tt.wantOK { + t.Errorf("InferResourceTypeFromToken(%q) ok = %v, want %v", tt.token, gotOK, tt.wantOK) + } + if gotType != tt.wantType { + t.Errorf("InferResourceTypeFromToken(%q) type = %q, want %q", tt.token, gotType, tt.wantType) + } + }) + } +} + func TestBuildResourceURL(t *testing.T) { t.Parallel() @@ -126,6 +167,7 @@ func TestBuildResourceURL(t *testing.T) { {"feishu file", core.BrandFeishu, "file", "boxcnABC", "https://www.feishu.cn/file/boxcnABC"}, {"feishu folder", core.BrandFeishu, "folder", "fldcnABC", "https://www.feishu.cn/drive/folder/fldcnABC"}, {"feishu mindnote", core.BrandFeishu, "mindnote", "mncnABC", "https://www.feishu.cn/mindnote/mncnABC"}, + {"feishu minutes", core.BrandFeishu, "minutes", "mincnABC", "https://www.feishu.cn/minutes/mincnABC"}, {"feishu slides", core.BrandFeishu, "slides", "slkcnABC", "https://www.feishu.cn/slides/slkcnABC"}, {"lark docx", core.BrandLark, "docx", "doxcnABC", "https://www.larksuite.com/docx/doxcnABC"}, {"lark wiki", core.BrandLark, "wiki", "wikcnABC", "https://www.larksuite.com/wiki/wikcnABC"}, diff --git a/shortcuts/drive/drive_member_add.go b/shortcuts/drive/drive_member_add.go new file mode 100644 index 000000000..66abba785 --- /dev/null +++ b/shortcuts/drive/drive_member_add.go @@ -0,0 +1,482 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// driveMemberAddIDTypes covers every user-facing --member-type value accepted +// by the shortcut. Some values are normalized before hitting the API. +var driveMemberAddIDTypes = []string{ + "email", "openid", "unionid", "openchat", "opendepartmentid", + "groupid", "appid", +} + +var driveMemberAddPerms = []string{"view", "edit", "full_access"} +var driveMemberAddPermTypes = []string{"container", "single_page"} + +// driveMemberAddPrefixToType maps ID prefixes to their expected member_type +// for conflict validation when --member-type is provided explicitly. +var driveMemberAddPrefixToType = map[string]string{ + "ou_": "openid", + "on_": "unionid", + "oc_": "openchat", + "od_": "opendepartmentid", +} + +// These lists mirror shortcuts/common resource URL parsing and token-prefix +// inference. They stay local so validation errors can name the accepted shapes. +var driveMemberAddSupportedURLPaths = []string{ + "/docx/", "/doc/", "/sheets/", "/base/", "/bitable/", "/file/", + "/drive/file/", "/drive/folder/", "/drive/shr/", "/chat/drive/", + "/wiki/", "/mindnote/", "/minutes/", "/slides/", +} + +var driveMemberAddResourceTokenPrefixes = []string{ + "doxcn", "doccn", "shtcn", "bascn", "fldcn", "wikcn", "mncn", "mincn", "slkcn", "boxcn", +} + +var driveMemberAddTrustedHostSuffixes = []string{"feishu.cn", "larksuite.com", "larkoffice.com", "doubao.com"} + +// driveMemberAddAPIMemberTypeAliases maps shortcut-friendly member types to the +// exact Drive API member_type values. +var driveMemberAddAPIMemberTypeAliases = map[string]string{ + "appid": "userid", +} + +const driveMemberAddBatchLimit = 10 + +// DriveMemberAdd adds a collaborator/member permission to a Drive resource. +var DriveMemberAdd = common.Shortcut{ + Service: "drive", + Command: "+member-add", + Description: "Add a collaborator/member permission to a Drive document, file, folder, or wiki node", + Risk: "high-risk-write", + Scopes: []string{"docs:permission.member:create"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "token", Desc: "target token or document URL; resource type is auto-inferred from URL path or token prefix", Required: true}, + {Name: "member-id", Desc: "collaborator ID; comma-separated for batch (max 10). Interpretation is decided by --member-type", Required: true}, + {Name: "member-type", Desc: "ID type for --member-id; supported: email|openid|unionid|openchat|opendepartmentid|groupid|appid (appid is sent to the API as userid)", Enum: driveMemberAddIDTypes, Required: true}, + {Name: "perm", Desc: "permission role to grant; defaults to view", Enum: driveMemberAddPerms}, + {Name: "perm-type", Desc: "wiki permission scope; defaults to container; rejected for non-wiki types", Enum: driveMemberAddPermTypes}, + {Name: "need-notification", Type: "bool", Desc: "send an in-app notification after the grant (user identity only)"}, + }, + Tips: []string{ + "Resource type is auto-inferred from trusted Feishu/Lark/Doubao URL paths or bare token prefixes (doxcn→docx, shtcn→sheet, etc.).", + "Supported --member-type values: email, openid, unionid, openchat, opendepartmentid, groupid, appid.", + "When --member-type=appid, the CLI sends member_type=userid to the Drive API because app identity is modeled under userid there.", + "--member-type is required; if the ID prefix conflicts with --member-type (e.g. ou_xxx with email), the command rejects it.", + "--perm defaults to view (safest); use --dry-run first when granting edit or full_access.", + "For wiki nodes, --perm-type defaults to container (current page + sub-pages); pass --perm-type single_page for only the current page.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := readDriveMemberAddSpec(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec, err := readDriveMemberAddSpec(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return buildDriveMemberAddDryRun(spec) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec, err := readDriveMemberAddSpec(runtime) + if err != nil { + return err + } + + memberKind := inferDriveMemberKind(spec.MemberType) + + if len(spec.MemberIDs) == 1 { + return executeDriveMemberAddSingle(runtime, spec, memberKind) + } + return executeDriveMemberAddBatch(runtime, spec, memberKind) + }, +} + +// driveMemberAddSpec is the normalized request model shared by Validate, +// DryRun, Execute, and output shaping so they all observe the same defaults. +type driveMemberAddSpec struct { + Token string + ResourceType string + MemberIDs []string + MemberType string + Perm string + PermType string + NeedNotification bool + NotificationSet bool +} + +// DryRunParams builds the preview query string while preserving the semantic +// difference between an omitted notification flag and an explicit false. +func (spec driveMemberAddSpec) DryRunParams() map[string]interface{} { + params := map[string]interface{}{"type": spec.ResourceType} + if spec.NotificationSet { + params["need_notification"] = spec.NeedNotification + } + return params +} + +// APIQueryParams builds the SDK query params for permission.members.create. +func (spec driveMemberAddSpec) APIQueryParams() larkcore.QueryParams { + params := larkcore.QueryParams{"type": []string{spec.ResourceType}} + if spec.NotificationSet { + params["need_notification"] = []string{strconv.FormatBool(spec.NeedNotification)} + } + return params +} + +// buildMemberBody builds a single member object for the request body. +func buildMemberBody(memberID, memberType, perm, memberKind, permType string) map[string]interface{} { + body := map[string]interface{}{ + "member_id": memberID, + "member_type": memberType, + "perm": perm, + } + if memberKind != "" { + body["type"] = memberKind + } + if permType != "" { + body["perm_type"] = permType + } + return body +} + +// readDriveMemberAddSpec parses runtime flags into a normalized request model, +// applying inference, defaults, and cross-field validation in one place. +func readDriveMemberAddSpec(runtime *common.RuntimeContext) (driveMemberAddSpec, error) { + token, resourceType, err := resolveDriveMemberAddTarget(runtime.Str("token")) + if err != nil { + return driveMemberAddSpec{}, err + } + + // Parse member-id: comma-separated for batch. + rawMemberID := strings.TrimSpace(runtime.Str("member-id")) + if rawMemberID == "" { + return driveMemberAddSpec{}, output.ErrValidation("--member-id is required and cannot be blank") + } + memberIDs := splitAndTrimMembers(rawMemberID) + if len(memberIDs) == 0 { + return driveMemberAddSpec{}, output.ErrValidation("--member-id is required and must contain at least one non-blank ID") + } + if len(memberIDs) > driveMemberAddBatchLimit { + return driveMemberAddSpec{}, output.ErrValidation("--member-id accepts at most %d IDs, got %d", driveMemberAddBatchLimit, len(memberIDs)) + } + + memberType, err := resolveDriveMemberAddMemberType(memberIDs, runtime.Str("member-type")) + if err != nil { + return driveMemberAddSpec{}, err + } + + // perm: default to view. + perm := strings.ToLower(strings.TrimSpace(runtime.Str("perm"))) + if perm == "" { + perm = "view" + } + + // perm-type: only meaningful for wiki; default container. + permType := strings.ToLower(strings.TrimSpace(runtime.Str("perm-type"))) + if resourceType == "wiki" && permType == "" { + permType = "container" + } else if resourceType != "wiki" && runtime.Changed("perm-type") { + return driveMemberAddSpec{}, output.ErrValidation("--perm-type only applies when resource type is wiki; got %q", resourceType) + } else if resourceType != "wiki" { + permType = "" + } + + spec := driveMemberAddSpec{ + Token: token, + ResourceType: resourceType, + MemberIDs: memberIDs, + MemberType: memberType, + Perm: perm, + PermType: permType, + NeedNotification: runtime.Bool("need-notification"), + NotificationSet: runtime.Changed("need-notification"), + } + if runtime.As().IsBot() && spec.NotificationSet { + return driveMemberAddSpec{}, output.ErrValidation("--need-notification is only valid with --as user; omit it when using --as bot") + } + return spec, nil +} + +// resolveDriveMemberAddTarget extracts (token, type) from a user-supplied +// --token value that may be either a bare token or a full resource URL. +func resolveDriveMemberAddTarget(raw string) (token, resourceType string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", output.ErrValidation("--token is required") + } + + if strings.Contains(raw, "://") { + parsed, parseErr := url.Parse(raw) + if parseErr != nil || parsed.Hostname() == "" { + return "", "", output.ErrValidation("--token URL is malformed: %q", raw) + } + if !isTrustedDriveMemberAddURLHost(parsed.Hostname()) { + return "", "", output.ErrValidation("unsupported URL host %q: expected a Feishu/Lark/Doubao document URL or a bare token", parsed.Hostname()) + } + ref, ok := common.ParseResourceURL(raw) + if !ok { + return "", "", output.ErrValidation( + "unsupported URL path %q: expected one of %s followed by a token", + parsed.Path, strings.Join(driveMemberAddSupportedURLPaths, ", "), + ) + } + return ref.Token, ref.Type, nil + } + + if inferred, ok := common.InferResourceTypeFromToken(raw); ok { + return raw, inferred, nil + } + + return "", "", output.ErrValidation( + "could not infer resource type from bare token %q: recognized prefixes are %s; otherwise pass a full trusted document URL", + raw, strings.Join(driveMemberAddResourceTokenPrefixes, ", "), + ) +} + +func isTrustedDriveMemberAddURLHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + return false + } + for _, suffix := range driveMemberAddTrustedHostSuffixes { + if host == suffix || strings.HasSuffix(host, "."+suffix) { + return true + } + } + return false +} + +func resolveDriveMemberAddMemberType(memberIDs []string, explicit string) (string, error) { + explicit = strings.ToLower(strings.TrimSpace(explicit)) + if explicit == "" { + return "", output.ErrValidation("--member-type is required; accepted values: %s", strings.Join(driveMemberAddIDTypes, ", ")) + } + for i, memberID := range memberIDs { + if expected := inferMemberTypeFromID(memberID); expected != "" && expected != explicit { + return "", output.ErrValidation( + "member-id[%d] %q prefix implies --member-type %s, but --member-type %s was provided; fix the ID or use the matching member type", + i+1, memberID, expected, explicit, + ) + } + } + return normalizeDriveMemberAddMemberType(explicit), nil +} + +func normalizeDriveMemberAddMemberType(memberType string) string { + memberType = strings.ToLower(strings.TrimSpace(memberType)) + if alias, ok := driveMemberAddAPIMemberTypeAliases[memberType]; ok { + return alias + } + return memberType +} + +func displayDriveMemberAddMemberType(memberType string) string { + memberType = strings.ToLower(strings.TrimSpace(memberType)) + if memberType == "userid" { + return "appid" + } + return memberType +} + +// splitAndTrimMembers splits a comma-separated member-id string and trims whitespace. +func splitAndTrimMembers(raw string) []string { + parts := strings.Split(raw, ",") + var result []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +// inferMemberTypeFromID returns the expected member_type for a member-id +// based on its prefix, or "" if no prefix matches (e.g. groupid). +func inferMemberTypeFromID(memberID string) string { + memberID = strings.TrimSpace(memberID) + if memberID == "" { + return "" + } + if strings.Contains(memberID, "@") { + return "email" + } + for prefix, mtype := range driveMemberAddPrefixToType { + if strings.HasPrefix(memberID, prefix) { + return mtype + } + } + return "" +} + +// inferDriveMemberKind derives the request-body collaborator kind from +// member-type for all supported member-type values. +func inferDriveMemberKind(memberType string) string { + switch memberType { + case "email", "openid", "unionid", "userid": + return "user" + case "openchat": + return "chat" + case "opendepartmentid": + return "department" + case "groupid": + return "group" + default: + return "" + } +} + +// buildDriveMemberAddDryRun renders the exact request preview for --dry-run. +func buildDriveMemberAddDryRun(spec driveMemberAddSpec) *common.DryRunAPI { + memberKind := inferDriveMemberKind(spec.MemberType) + + if len(spec.MemberIDs) == 1 { + body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.Perm, memberKind, spec.PermType) + return common.NewDryRunAPI(). + Desc("Add Drive collaborator/member permission"). + POST(fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(spec.Token))). + Params(spec.DryRunParams()). + Body(body) + } + + // Batch: build members array. + members := make([]map[string]interface{}, len(spec.MemberIDs)) + for i, mid := range spec.MemberIDs { + members[i] = buildMemberBody(mid, spec.MemberType, spec.Perm, memberKind, spec.PermType) + } + return common.NewDryRunAPI(). + Desc("Batch add Drive collaborator/member permissions"). + POST(fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/batch_create", validate.EncodePathSegment(spec.Token))). + Params(spec.DryRunParams()). + Body(map[string]interface{}{"members": members}) +} + +// executeDriveMemberAddSingle calls the single-member create API. +func executeDriveMemberAddSingle(runtime *common.RuntimeContext, spec driveMemberAddSpec, memberKind string) error { + fmt.Fprintf(runtime.IO().ErrOut, "Adding Drive member %s (type=%s, perm=%s) to %s %s...\n", + common.MaskToken(spec.MemberIDs[0]), displayDriveMemberAddMemberType(spec.MemberType), spec.Perm, spec.ResourceType, common.MaskToken(spec.Token)) + + body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.Perm, memberKind, spec.PermType) + data, err := runtime.DoAPIJSON( + http.MethodPost, + fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(spec.Token)), + spec.APIQueryParams(), + body, + ) + if err != nil { + return err + } + + out := driveMemberAddOutput(spec, spec.MemberIDs[0], common.GetMap(data, "member")) + fmt.Fprintf(runtime.IO().ErrOut, "Added Drive member %s\n", common.MaskToken(common.GetString(out, "member_id"))) + runtime.Out(out, nil) + return nil +} + +// executeDriveMemberAddBatch calls the batch_create API. +func executeDriveMemberAddBatch(runtime *common.RuntimeContext, spec driveMemberAddSpec, memberKind string) error { + members := make([]map[string]interface{}, len(spec.MemberIDs)) + for i, mid := range spec.MemberIDs { + members[i] = buildMemberBody(mid, spec.MemberType, spec.Perm, memberKind, spec.PermType) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Adding %d Drive members (type=%s, perm=%s) to %s %s...\n", + len(spec.MemberIDs), displayDriveMemberAddMemberType(spec.MemberType), spec.Perm, spec.ResourceType, common.MaskToken(spec.Token)) + + data, err := runtime.DoAPIJSON( + http.MethodPost, + fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/batch_create", validate.EncodePathSegment(spec.Token)), + spec.APIQueryParams(), + map[string]interface{}{"members": members}, + ) + if err != nil { + return err + } + + // Flatten batch response: data.members is an array of member objects. + rawMembers, _ := data["members"].([]interface{}) + var results []map[string]interface{} + for i, raw := range rawMembers { + if m, ok := raw.(map[string]interface{}); ok { + memberID := "" + if i < len(spec.MemberIDs) { + memberID = spec.MemberIDs[i] + } + out := driveMemberAddOutput(spec, memberID, m) + results = append(results, out) + } + } + if len(results) == 0 { + // Fallback: API returned success but no member details; backfill from spec. + for _, mid := range spec.MemberIDs { + results = append(results, driveMemberAddOutput(spec, mid, nil)) + } + } + fmt.Fprintf(runtime.IO().ErrOut, "Added %d Drive member(s)\n", len(results)) + runtime.Out(map[string]interface{}{ + "resource_token": spec.Token, + "resource_type": spec.ResourceType, + "members": results, + }, nil) + return nil +} + +// driveMemberAddOutput flattens the server response into a stable envelope and +// backfills fields from spec when the server omits them. +func driveMemberAddOutput(spec driveMemberAddSpec, fallbackMemberID string, raw map[string]interface{}) map[string]interface{} { + out := map[string]interface{}{ + "resource_token": spec.Token, + "resource_type": spec.ResourceType, + } + if raw != nil { + for _, key := range []string{"member_id", "member_type", "perm", "perm_type", "type"} { + if v, ok := raw[key]; ok { + out[key] = v + } + } + } + if common.GetString(out, "member_id") == "" { + if fallbackMemberID == "" && len(spec.MemberIDs) > 0 { + fallbackMemberID = spec.MemberIDs[0] + } + out["member_id"] = fallbackMemberID + } + if common.GetString(out, "member_type") == "" { + out["member_type"] = displayDriveMemberAddMemberType(spec.MemberType) + } else if spec.MemberType == "userid" && common.GetString(out, "member_type") == "userid" { + out["member_type"] = "appid" + } + if common.GetString(out, "perm") == "" { + out["perm"] = spec.Perm + } + if spec.PermType != "" && common.GetString(out, "perm_type") == "" { + out["perm_type"] = spec.PermType + } + memberKind := inferDriveMemberKind(spec.MemberType) + if memberKind != "" && common.GetString(out, "type") == "" { + out["type"] = memberKind + } + if t := common.GetString(out, "type"); t != "" { + out["member_kind"] = t + } + delete(out, "type") + return out +} diff --git a/shortcuts/drive/drive_member_add_test.go b/shortcuts/drive/drive_member_add_test.go new file mode 100644 index 000000000..f4d3ca3fe --- /dev/null +++ b/shortcuts/drive/drive_member_add_test.go @@ -0,0 +1,734 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +// ── resolveDriveMemberAddTarget unit tests ────────────────────────────────── + +func TestResolveDriveMemberAddTarget_URLAndBareToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + wantTok string + wantType string + }{ + {"docx URL", "https://example.feishu.cn/docx/doxTok?from=share", "doxTok", "docx"}, + {"folder URL", "https://example.feishu.cn/drive/folder/fldTok", "fldTok", "folder"}, + {"minutes URL", "https://example.feishu.cn/minutes/minTok", "minTok", "minutes"}, + {"wiki URL", "https://example.feishu.cn/wiki/wikTok", "wikTok", "wiki"}, + {"larkoffice URL", "https://tenant.larkoffice.com/docx/doxTok", "doxTok", "docx"}, + {"bare doxcn", "doxcnTok123", "doxcnTok123", "docx"}, + {"bare shtcn", "shtcnTok456", "shtcnTok456", "sheet"}, + {"bare wikcn", "wikcnTok789", "wikcnTok789", "wiki"}, + {"bare fldcn", "fldcnFolder1", "fldcnFolder1", "folder"}, + } + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + token, resourceType, err := resolveDriveMemberAddTarget(tt.raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != tt.wantTok || resourceType != tt.wantType { + t.Fatalf("got token=%q type=%q, want %q/%q", token, resourceType, tt.wantTok, tt.wantType) + } + }) + } +} + +func TestResolveDriveMemberAddTarget_RejectsUnrecognizedBareToken(t *testing.T) { + t.Parallel() + + _, _, err := resolveDriveMemberAddTarget("unrecognizedToken") + if err == nil || !strings.Contains(err.Error(), "could not infer resource type") { + t.Fatalf("expected resource type inference error, got: %v", err) + } +} + +func TestResolveDriveMemberAddTarget_RejectsUnsupportedURLHost(t *testing.T) { + t.Parallel() + + _, _, err := resolveDriveMemberAddTarget("https://google.com/docx/doxTok") + if err == nil || !strings.Contains(err.Error(), "unsupported URL host") { + t.Fatalf("expected unsupported URL host error, got: %v", err) + } +} + +func TestResolveDriveMemberAddTarget_RejectsUnsupportedURLPath(t *testing.T) { + t.Parallel() + + _, _, err := resolveDriveMemberAddTarget("https://example.feishu.cn/calendar/calTok") + if err == nil || !strings.Contains(err.Error(), "unsupported URL path") { + t.Fatalf("expected unsupported URL path error, got: %v", err) + } +} + +func TestResolveDriveMemberAddTarget_RejectsEmpty(t *testing.T) { + t.Parallel() + + _, _, err := resolveDriveMemberAddTarget("") + if err == nil || !strings.Contains(err.Error(), "--token is required") { + t.Fatalf("expected --token required error, got: %v", err) + } +} + +// ── inferMemberTypeFromID unit tests ──────────────────────────────────────── + +func TestInferMemberTypeFromID(t *testing.T) { + t.Parallel() + + tests := []struct { + memberID string + want string + }{ + {"ou_xxx", "openid"}, + {"on_xxx", "unionid"}, + {"oc_xxx", "openchat"}, + {"od_xxx", "opendepartmentid"}, + {"user@example.com", "email"}, + {"ambiguous", ""}, + {"", ""}, + } + for _, tt := range tests { + got := inferMemberTypeFromID(tt.memberID) + if got != tt.want { + t.Errorf("inferMemberTypeFromID(%q) = %q, want %q", tt.memberID, got, tt.want) + } + } +} + +func TestResolveDriveMemberAddMemberType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + memberIDs []string + explicit string + wantType string + wantErr string + }{ + { + name: "single explicit openid", + memberIDs: []string{"ou_x"}, + explicit: "openid", + wantType: "openid", + }, + { + name: "batch explicit openchat", + memberIDs: []string{"oc_a", "oc_b"}, + explicit: "openchat", + wantType: "openchat", + }, + { + name: "explicit groupid", + memberIDs: []string{"group_1"}, + explicit: "groupid", + wantType: "groupid", + }, + { + name: "explicit appid alias", + memberIDs: []string{"cli_xxx"}, + explicit: "appid", + wantType: "userid", + }, + { + name: "missing member-type rejected", + memberIDs: []string{"ou_a"}, + wantErr: "--member-type is required", + }, + { + name: "prefix conflicts with explicit type", + memberIDs: []string{"oc_chat"}, + explicit: "openid", + wantErr: "implies --member-type openchat", + }, + { + name: "email prefix matches explicit email", + memberIDs: []string{"user@example.com"}, + explicit: "email", + wantType: "email", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotType, err := resolveDriveMemberAddMemberType(tt.memberIDs, tt.explicit) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotType != tt.wantType { + t.Fatalf("got type=%q, want %q", gotType, tt.wantType) + } + }) + } +} + +func TestNormalizeDriveMemberAddMemberType(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want string + }{ + {"openid", "openid"}, + {"groupid", "groupid"}, + {"appid", "userid"}, + } + for _, tt := range tests { + if got := normalizeDriveMemberAddMemberType(tt.in); got != tt.want { + t.Fatalf("normalizeDriveMemberAddMemberType(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestDisplayDriveMemberAddMemberType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + memberType string + want string + }{ + { + name: "maps userid to appid", + memberType: "userid", + want: "appid", + }, + { + name: "returns ordinary member type unchanged", + memberType: "openid", + want: "openid", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := displayDriveMemberAddMemberType(tt.memberType); got != tt.want { + t.Fatalf("displayDriveMemberAddMemberType(%q) = %q, want %q", tt.memberType, got, tt.want) + } + }) + } +} + +// ── splitAndTrimMembers unit tests ────────────────────────────────────────── + +func TestSplitAndTrimMembers(t *testing.T) { + t.Parallel() + + tests := []struct { + raw string + want []string + }{ + {"ou_a", []string{"ou_a"}}, + {"ou_a,ou_b,ou_c", []string{"ou_a", "ou_b", "ou_c"}}, + {" ou_a , ou_b ", []string{"ou_a", "ou_b"}}, + {"ou_a,,ou_b", []string{"ou_a", "ou_b"}}, + } + for _, tt := range tests { + got := splitAndTrimMembers(tt.raw) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("splitAndTrimMembers(%q) = %#v, want %#v", tt.raw, got, tt.want) + } + } +} + +// ── spec body/query construction unit tests ───────────────────────────────── + +func TestDriveMemberAddSpec_BuildsBodyAndQuery(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + Token: "doxTok", + ResourceType: "docx", + MemberIDs: []string{"ou_x"}, + MemberType: "openid", + Perm: "edit", + } + if got := spec.DryRunParams(); !reflect.DeepEqual(got, map[string]interface{}{"type": "docx"}) { + t.Fatalf("DryRunParams() = %#v", got) + } + if got := map[string][]string(spec.APIQueryParams()); !reflect.DeepEqual(got, map[string][]string{"type": {"docx"}}) { + t.Fatalf("APIQueryParams() = %#v", got) + } + wantBody := map[string]interface{}{ + "member_id": "ou_x", + "member_type": "openid", + "perm": "edit", + "type": "user", + } + if got := buildMemberBody("ou_x", "openid", "edit", "user", ""); !reflect.DeepEqual(got, wantBody) { + t.Fatalf("buildMemberBody() = %#v, want %#v", got, wantBody) + } +} + +func TestDriveMemberAddSpec_HonorsExplicitNotificationFalse(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + ResourceType: "docx", + NotificationSet: true, + NeedNotification: false, + } + if got := spec.DryRunParams(); !reflect.DeepEqual(got, map[string]interface{}{"type": "docx", "need_notification": false}) { + t.Fatalf("DryRunParams() = %#v", got) + } + if got := map[string][]string(spec.APIQueryParams()); !reflect.DeepEqual(got, map[string][]string{"type": {"docx"}, "need_notification": {"false"}}) { + t.Fatalf("APIQueryParams() = %#v", got) + } +} + +func TestDriveMemberAddOutputBackfillsProvidedMemberID(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + Token: "doxTok", + ResourceType: "docx", + MemberIDs: []string{"ou_a", "ou_b"}, + MemberType: "openid", + Perm: "view", + } + out := driveMemberAddOutput(spec, "ou_b", map[string]interface{}{"perm": "view"}) + if out["member_id"] != "ou_b" { + t.Fatalf("member_id = %v, want ou_b", out["member_id"]) + } +} + +func TestDriveMemberAddOutput_RewritesUserIDToAppIDAlias(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + Token: "doxTok", + ResourceType: "docx", + MemberIDs: []string{"cli_app_123"}, + MemberType: "userid", + Perm: "view", + } + out := driveMemberAddOutput(spec, "cli_app_123", map[string]interface{}{"member_type": "userid", "perm": "view"}) + if out["member_type"] != "appid" { + t.Fatalf("member_type = %v, want appid", out["member_type"]) + } +} + +// ── shortcut integration tests ────────────────────────────────────────────── + +func TestDriveMemberAdd_PermDefaultsToView(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--member-id", "ou_x", + "--member-type", "openid", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if got.API[0].Body["perm"] != "view" { + t.Fatalf("perm = %v, want view", got.API[0].Body["perm"]) + } +} + +func TestDriveMemberAdd_RejectsNotificationWithBot(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--member-id", "ou_x", + "--member-type", "openid", + "--perm", "view", + "--need-notification", + "--as", "bot", + "--yes", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--need-notification is only valid with --as user") { + t.Fatalf("expected bot notification validation error, got: %v", err) + } +} + +func TestDriveMemberAdd_AcceptsAmbiguousIDWithExplicitType(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/doxcnTok/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "member": map[string]interface{}{ + "member_id": "ambiguous_id", + "member_type": "openid", + "perm": "view", + "type": "user", + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--member-id", "ambiguous_id", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDriveMemberAdd_DryRunAcceptsAppIDAlias(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--member-id", "cli_app_123", + "--member-type", "appid", + "--perm", "view", + "--dry-run", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if got.API[0].Body["member_type"] != "userid" { + t.Fatalf("member_type = %v, want userid API alias", got.API[0].Body["member_type"]) + } + if got.API[0].Body["type"] != "user" { + t.Fatalf("type = %v, want user", got.API[0].Body["type"]) + } +} + +func TestDriveMemberAdd_RejectsBlankBatchMemberID(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--member-id", ",,,", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "at least one non-blank ID") { + t.Fatalf("expected blank member-id validation error, got: %v", err) + } +} + +func TestDriveMemberAdd_RejectsBatchOverLimit(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + ids := make([]string, 11) + for i := range ids { + ids[i] = fmt.Sprintf("ou_%d", i) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--member-id", strings.Join(ids, ","), + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "at most 10") { + t.Fatalf("expected batch limit error, got: %v", err) + } +} + +func TestDriveMemberAdd_DryRunInfersTypeAndDefaultsWikiPermType(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "https://example.feishu.cn/wiki/wikTok?from=share", + "--member-id", "ou_x", + "--member-type", "openid", + "--perm", "full_access", + "--need-notification=false", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if len(got.API) != 1 { + t.Fatalf("api count = %d, want 1; stdout=%s", len(got.API), stdout.String()) + } + api := got.API[0] + if api.Method != "POST" || api.URL != "/open-apis/drive/v1/permissions/wikTok/members" { + t.Fatalf("api = %#v", api) + } + if api.Params["type"] != "wiki" || api.Params["need_notification"] != false { + t.Fatalf("params = %#v", api.Params) + } + if api.Body["member_id"] != "ou_x" || api.Body["member_type"] != "openid" || api.Body["perm"] != "full_access" || api.Body["type"] != "user" || api.Body["perm_type"] != "container" { + t.Fatalf("body = %#v", api.Body) + } +} + +func TestDriveMemberAdd_PermTypeRejectedForNonWiki(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--member-id", "ou_x", + "--member-type", "openid", + "--perm", "edit", + "--perm-type", "single_page", + "--dry-run", + "--as", "user", + }, f, stdout) + if err == nil { + t.Fatalf("expected validation error for --perm-type on non-wiki resource") + } + if got, want := err.Error(), "--perm-type only applies when resource type is wiki"; !strings.Contains(got, want) { + t.Fatalf("error %q does not contain %q", got, want) + } +} + +func TestDriveMemberAdd_DryRunBatch(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "shtcnTok", + "--member-id", "ou_a,ou_b,ou_c", + "--member-type", "openid", + "--perm", "edit", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if len(got.API) != 1 { + t.Fatalf("api count = %d, want 1", len(got.API)) + } + api := got.API[0] + if api.Method != "POST" || api.URL != "/open-apis/drive/v1/permissions/shtcnTok/members/batch_create" { + t.Fatalf("api = %#v", api) + } + members, ok := api.Body["members"].([]interface{}) + if !ok || len(members) != 3 { + t.Fatalf("body.members = %#v, want 3 items", api.Body["members"]) + } +} + +func TestDriveMemberAdd_ExecuteSuccessFlattensMember(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig()) + + var capturedQuery string + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/doxcnTok/members", + OnMatch: func(req *http.Request) { + capturedQuery = req.URL.RawQuery + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "member": map[string]interface{}{ + "member_id": "ou_x", + "member_type": "openid", + "perm": "view", + "type": "user", + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--member-id", "ou_x", + "--member-type", "openid", + "--perm", "view", + "--need-notification", + "--as", "user", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("decode captured body: %v\n%s", err, string(stub.CapturedBody)) + } + wantBody := map[string]interface{}{"member_id": "ou_x", "member_type": "openid", "perm": "view", "type": "user"} + if !reflect.DeepEqual(captured, wantBody) { + t.Fatalf("captured body = %#v, want %#v", captured, wantBody) + } + if !strings.Contains(capturedQuery, "type=docx") || !strings.Contains(capturedQuery, "need_notification=true") { + t.Fatalf("captured query = %q", capturedQuery) + } + + data := decodeDriveEnvelope(t, stdout) + if data["resource_token"] != "doxcnTok" || data["resource_type"] != "docx" || + data["member_id"] != "ou_x" || data["member_type"] != "openid" || + data["perm"] != "view" || data["member_kind"] != "user" { + t.Fatalf("flattened output = %#v", data) + } + if !strings.Contains(stderr.String(), "Added Drive member") { + t.Fatalf("stderr = %q, want success log", stderr.String()) + } +} + +func TestDriveMemberAdd_ExecuteSuccessAsBot(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/wikcnBotTok/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "member": map[string]interface{}{ + "member_id": "ou_bot_target", + "member_type": "openid", + "perm": "edit", + "type": "user", + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "wikcnBotTok", + "--member-id", "ou_bot_target", + "--member-type", "openid", + "--perm", "edit", + "--as", "bot", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Bot identity should NOT send need_notification in query. + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("decode captured body: %v", err) + } + if captured["perm"] != "edit" { + t.Fatalf("captured body perm = %v, want edit", captured["perm"]) + } + + data := decodeDriveEnvelope(t, stdout) + if data["resource_type"] != "wiki" || data["member_kind"] != "user" || data["perm"] != "edit" { + t.Fatalf("flattened output = %#v", data) + } +} + +func TestDriveMemberAdd_RequiresYesForHighRiskWrite(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "doxcnTok", + "--member-id", "ou_x", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "requires confirmation") { + t.Fatalf("expected confirmation error, got: %v", err) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index 91df7cc55..1e7896674 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -28,6 +28,7 @@ func Shortcuts() []common.Shortcut { DriveSync, DriveTaskResult, DriveApplyPermission, + DriveMemberAdd, DriveSecureLabelList, DriveSecureLabelUpdate, DriveSearch, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 6f170ce3e..5acb25a1b 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -31,6 +31,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+sync", "+task_result", "+apply-permission", + "+member-add", "+secure-label-list", "+secure-label-update", "+search", diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 678749fac..0bdd7b6b2 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-drive version: 1.0.0 -description: "飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题(docx、sheet、bitable、file、folder、wiki);也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base)导入为飞书在线云文档(docx、sheet、bitable、slides)。当用户需要上传或下载文件、整理云空间(云盘/云存储)目录、查看文件详情、管理评论、管理文档权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base/幻灯片 时使用。\"云空间\"、\"云盘\"和\"云存储\"是同一概念,用户说\"云盘\"、\"云存储\"、\"网盘\"、\"我的空间\"时均路由到本 skill。当用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是资源类型、URL 路径模式和 token,而不是域名。" +description: "飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、添加协作者/授权成员权限、订阅用户评论变更事件、修改文件标题(docx、sheet、bitable、file、folder、wiki);也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base)导入为飞书在线云文档(docx、sheet、bitable、slides)。当用户需要上传或下载文件、整理云空间(云盘/云存储)目录、查看文件详情、管理评论、管理文档权限、添加协作者/授权成员权限、修改文件标题、订阅用户评论变更事件,或要把本地文件导入成新版文档、电子表格、多维表格/Base/幻灯片 时使用。\"云空间\"、\"云盘\"和\"云存储\"是同一概念,用户说\"云盘\"、\"云存储\"、\"网盘\"、\"我的空间\"时均路由到本 skill。当用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是资源类型、URL 路径模式和 token,而不是域名。" metadata: requires: bins: ["lark-cli"] @@ -53,6 +53,8 @@ metadata: 知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。**不能直接假设 URL 中的 token 就是 file_token**,必须先查询实际类型和真实 token。 +例外:权限接口面向 wiki 节点本身时可以直接使用 wiki token,例如 `drive +member-add --token ` 会给该 wiki 节点添加协作者;若目标是底层 docx/sheet/bitable 文件权限,再先用 `drive +inspect` 解包。 + #### 处理流程 **推荐方式:使用 `drive +inspect` 自动解包** @@ -238,7 +240,7 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": " ### 授权当前应用访问文档 -当需要将文档权限授予**当前应用(bot)自身**时,先通过 bot info 接口获取应用的 open_id,再调用权限接口授权: +当需要将文档权限授予**当前应用(bot)自身**时,先通过 bot info 接口获取应用的 open_id,再调用 `drive +member-add` 授权: ```bash # 1. 获取当前应用的 open_id @@ -246,14 +248,17 @@ lark-cli api GET /open-apis/bot/v3/info --as bot # 从返回值中取 bot.open_id # 2. 授权当前应用访问文档 -lark-cli drive permission.members create \ - --params '{"token":"","type":""}' \ - --data '{"member_type":"openid","member_id":"","perm":"view","type":"user"}' +lark-cli drive +member-add \ + --token "" \ + --member-id "" \ + --member-type openid \ + --perm view \ + --yes ``` -> **注意**:此方式仅适用于需要授权给**当前应用**的场景。授权给其他用户时,直接使用对方的 open_id 即可,无需调用 bot info 接口。 +> **注意**:此方式仅适用于需要授权给**当前应用**的场景。授权给其他用户时,直接使用对方的 open_id 即可,无需调用 bot info 接口。`drive +member-add` 会从可信飞书/Lark/豆包 URL 或裸 token 前缀推断 resource type;`--member-type` 必须显式传入,如果 ID 前缀与 member-type 不一致会报错。 -`` 可选值:`doc`、`docx`、`sheet`、`bitable`、`file`、`folder`、`wiki`、`slides`。 +> **补充**:如果你手里只有 app id,也可以把 `--member-type` 设为 `appid`;CLI 会在请求层兼容映射为 Drive API 的 `member_type=userid`。但在 skill 文档和实际操作里,仍然优先推荐使用 bot `open_id` + `--member-type openid`,因为这条路径语义更直接、和仓库里其他实现也更一致。 ## Shortcuts(推荐优先使用) @@ -283,6 +288,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations | | [`+inspect`](references/lark-drive-inspect.md) | Inspect a Lark document URL to get its type, title, and canonical token; auto-unwraps wiki URLs to the underlying document | | [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) | +| [`+member-add`](references/lark-drive-member-add.md) | Add a collaborator/member permission to a Drive document, file, folder, or wiki node; wraps `permission.members.create` and requires `--yes` for real writes | | [`+secure-label-list`](references/lark-drive-secure-label.md) | List secure labels available to the current user | | [`+secure-label-update`](references/lark-drive-secure-label.md) | Update a Drive file/document secure label; downgrade approval errors require opening the document UI | diff --git a/skills/lark-drive/references/lark-drive-member-add.md b/skills/lark-drive/references/lark-drive-member-add.md new file mode 100644 index 000000000..95f0245fc --- /dev/null +++ b/skills/lark-drive/references/lark-drive-member-add.md @@ -0,0 +1,123 @@ +# drive +member-add(添加协作者) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和高风险写操作确认规则。 + +本 skill 对应 shortcut:`lark-cli drive +member-add`。 + +给云文档、云空间文件、文件夹或 wiki 节点直接添加协作者权限。底层 OpenAPI:`POST /open-apis/drive/v1/permissions/:token/members`(单个)或 `/batch_create`(批量)。 + +> 这是高风险写操作。真实执行会修改文档权限,需要显式加 `--yes`;可先用 `--dry-run` 预览请求。 + +## 命令 + +```bash +# 通过可信 URL 添加用户协作者 +lark-cli drive +member-add \ + --token "https://example.feishu.cn/docx/doxcnxxxxxxxxx" \ + --member-id "ou_xxxxxxxxxx" \ + --member-type openid \ + --perm view \ + --dry-run + +# 通过裸 token 添加;resource type 从前缀自动推断(wikcn→wiki) +lark-cli drive +member-add \ + --token "wikcnxxxxxxxxx" \ + --member-id "ou_xxxxxxxxxx" \ + --member-type openid \ + --perm full_access \ + --yes + +# 用邮箱添加协作者 +lark-cli drive +member-add \ + --token "shtcnxxxxxxxxx" \ + --member-id "user@example.com" \ + --member-type email \ + --perm edit \ + --yes + +# 批量添加(逗号分隔,最多 10 个) +lark-cli drive +member-add \ + --token "bascnxxxxxxxxx" \ + --member-id "ou_a,ou_b,ou_c" \ + --member-type openid \ + --perm view \ + --yes + +# groupid 类型(无法从 ID 前缀推断) +lark-cli drive +member-add \ + --token "doxcnxxxxxxxxx" \ + --member-id "grp_abc" \ + --member-type groupid \ + --perm edit \ + --yes + +# appid 兼容别名(仅当你手里只有 app id 时再用) +lark-cli drive +member-add \ + --token "doxcnxxxxxxxxx" \ + --member-id "cli_app_123" \ + --member-type appid \ + --perm view \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--token` | 是 | 目标 token 或完整 URL。URL host 必须是可信飞书/Lark/豆包域名;路径支持 `/docx/`、`/sheets/`、`/base/`、`/bitable/`、`/file/`、`/wiki/`、`/doc/`、`/mindnote/`、`/minutes/`、`/slides/`、`/drive/file/`、`/drive/folder/`、`/drive/shr/`、`/chat/drive/`;裸 token 从前缀自动推断(doxcn→docx, shtcn→sheet, bascn→bitable, fldcn→folder, wikcn→wiki, mncn→mindnote, mincn→minutes, slkcn→slides, boxcn→file, doccn→doc) | +| `--member-id` | 是 | 协作者 ID;逗号分隔可批量添加(最多 10 个) | +| `--member-type` | 是 | member-id 的类型;支持 `email` / `openid` / `unionid` / `openchat` / `opendepartmentid` / `groupid` / `appid`。其中 `appid` 是兼容性别名,CLI 会把它映射为 Drive API 的 `member_type=userid`;在实际使用里,给当前应用授权仍优先推荐 bot `open_id` + `openid`。如果 ID 前缀与 member-type 不一致会报错(如 `ou_xxx` 配 `email`) | +| `--perm` | 否 | 授权角色:`view`(默认)/ `edit` / `full_access` | +| `--perm-type` | 否 | wiki 节点权限范围:`container`(默认,当前页面+子页面)/ `single_page`(仅当前页面);非 wiki 类型会报错 | +| `--need-notification` | 否 | 是否通知对方。仅 `--as user` 可用;未传时不会写入 query,`--need-notification=false` 表示显式不通知 | +| `--dry-run` | 否 | 仅打印请求,不实际授权 | +| `--yes` | 真实执行时是 | 确认高风险写操作 | + +## 输出 + +单个协作者成功时输出扁平结构: + +```json +{ + "ok": true, + "identity": "user", + "data": { + "resource_token": "doxcnxxxxxxxxx", + "resource_type": "docx", + "member_id": "ou_xxxxxxxxxx", + "member_type": "openid", + "member_kind": "user", + "perm": "view" + } +} +``` + +批量协作者成功时输出嵌套结构: + +```json +{ + "ok": true, + "identity": "user", + "data": { + "resource_token": "bascnxxxxxxxxx", + "resource_type": "bitable", + "members": [ + {"resource_token": "bascnxxxxxxxxx", "resource_type": "bitable", "member_id": "ou_a", "member_type": "openid", "member_kind": "user", "perm": "view"}, + {"resource_token": "bascnxxxxxxxxx", "resource_type": "bitable", "member_id": "ou_b", "member_type": "openid", "member_kind": "user", "perm": "view"} + ] + } +} +``` + +## 身份和权限 + +- 支持 `--as user` 和 `--as bot`。 +- 所需 scope:`docs:permission.member:create`。 +- `--need-notification` 仅在 `--as user` 时有效;`--as bot` 传入该 flag 会被 CLI 拒绝,避免误以为服务端已通知协作者。 + +## 常见用法 + +- 不知道用户 open_id 时,可以用 `--member-id user@example.com --member-type email`。 +- 给当前应用自身授权时,优先使用 bot 的 `open_id`(`--member-type openid`);只有在你手里没有 bot `open_id`、只有 app id 时,才使用 `--member-type appid` 这个兼容别名。 +- wiki URL 会按 wiki node 授权,不会自动解包到底层 docx/sheet。若要给底层文档授权,先用 `drive +inspect` 获取真实 token 和 type。 +- 批量添加时所有协作者共享相同的 `--perm`、`--member-type` 和 `--perm-type`。 diff --git a/tests/cli_e2e/drive/drive_member_add_dryrun_test.go b/tests/cli_e2e/drive/drive_member_add_dryrun_test.go new file mode 100644 index 000000000..1b57d0e9d --- /dev/null +++ b/tests/cli_e2e/drive/drive_member_add_dryrun_test.go @@ -0,0 +1,323 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestDrive_MemberAddDryRun pins the request shape emitted by --dry-run. +func TestDrive_MemberAddDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + tests := []struct { + name string + args []string + wantURL string + wantResourceType string + wantNeedNotification string + wantMemberID string + wantMemberType string + wantPerm string + wantMemberKind string + wantPermType string + wantBatch bool + }{ + { + name: "docx URL with explicit member-type", + args: []string{ + "drive", "+member-add", + "--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--perm", "view", + "--need-notification=false", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E001/members", + wantResourceType: "docx", + wantNeedNotification: "false", + wantMemberID: "ou_e2e_user", + wantMemberType: "openid", + wantPerm: "view", + wantMemberKind: "user", + }, + { + name: "bare wiki token defaults perm_type container", + args: []string{ + "drive", "+member-add", + "--token", "wikcnE2E002", + "--member-id", "ou_e2e_admin", + "--member-type", "openid", + "--perm", "full_access", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/wikcnE2E002/members", + wantResourceType: "wiki", + wantMemberID: "ou_e2e_admin", + wantMemberType: "openid", + wantPerm: "full_access", + wantMemberKind: "user", + wantPermType: "container", + }, + { + name: "email member-type", + args: []string{ + "drive", "+member-add", + "--token", "shtcnE2E003", + "--member-id", "user@example.com", + "--member-type", "email", + "--perm", "edit", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/shtcnE2E003/members", + wantResourceType: "sheet", + wantMemberID: "user@example.com", + wantMemberType: "email", + wantPerm: "edit", + wantMemberKind: "user", + }, + { + name: "unionid member-type", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E006", + "--member-id", "on_e2e_union", + "--member-type", "unionid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E006/members", + wantResourceType: "docx", + wantMemberID: "on_e2e_union", + wantMemberType: "unionid", + wantPerm: "view", + wantMemberKind: "user", + }, + { + name: "explicit-only groupid member-type", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E007", + "--member-id", "group_e2e", + "--member-type", "groupid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E007/members", + wantResourceType: "docx", + wantMemberID: "group_e2e", + wantMemberType: "groupid", + wantPerm: "view", + wantMemberKind: "group", + }, + { + name: "batch members use batch_create endpoint", + args: []string{ + "drive", "+member-add", + "--token", "bascnE2E004", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/bascnE2E004/members/batch_create", + wantResourceType: "bitable", + wantMemberID: "ou_a", + wantMemberType: "openid", + wantPerm: "view", + wantMemberKind: "user", + wantBatch: true, + }, + { + name: "explicit groupid member-type", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E005", + "--member-id", "grp_abc", + "--member-type", "groupid", + "--perm", "edit", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E005/members", + wantResourceType: "docx", + wantMemberID: "grp_abc", + wantMemberType: "groupid", + wantPerm: "edit", + wantMemberKind: "group", + }, + { + name: "appid alias is normalized to userid in API body", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E008", + "--member-id", "cli_app_123", + "--member-type", "appid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E008/members", + wantResourceType: "docx", + wantMemberID: "cli_app_123", + wantMemberType: "userid", + wantPerm: "view", + wantMemberKind: "user", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != "POST" { + t.Fatalf("method = %q, want POST\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL { + t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out) + } + if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantResourceType { + t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantResourceType, out) + } + notification := gjson.Get(out, "api.0.params.need_notification") + if tt.wantNeedNotification == "" { + if notification.Exists() { + t.Fatalf("need_notification should be omitted\nstdout:\n%s", out) + } + } else if got := notification.String(); got != tt.wantNeedNotification { + t.Fatalf("need_notification = %q, want %q\nstdout:\n%s", got, tt.wantNeedNotification, out) + } + bodyPath := "api.0.body" + if tt.wantBatch { + bodyPath = "api.0.body.members.0" + if count := len(gjson.Get(out, "api.0.body.members").Array()); count != 2 { + t.Fatalf("body.members count = %d, want 2\nstdout:\n%s", count, out) + } + } + if got := gjson.Get(out, bodyPath+".member_id").String(); got != tt.wantMemberID { + t.Fatalf("body.member_id = %q, want %q\nstdout:\n%s", got, tt.wantMemberID, out) + } + if got := gjson.Get(out, bodyPath+".member_type").String(); got != tt.wantMemberType { + t.Fatalf("body.member_type = %q, want %q\nstdout:\n%s", got, tt.wantMemberType, out) + } + if got := gjson.Get(out, bodyPath+".perm").String(); got != tt.wantPerm { + t.Fatalf("body.perm = %q, want %q\nstdout:\n%s", got, tt.wantPerm, out) + } + if got := gjson.Get(out, bodyPath+".type").String(); got != tt.wantMemberKind { + t.Fatalf("body.type = %q, want %q\nstdout:\n%s", got, tt.wantMemberKind, out) + } + permType := gjson.Get(out, bodyPath+".perm_type") + if tt.wantPermType == "" { + if permType.Exists() { + t.Fatalf("perm_type should be omitted\nstdout:\n%s", out) + } + } else if got := permType.String(); got != tt.wantPermType { + t.Fatalf("body.perm_type = %q, want %q\nstdout:\n%s", got, tt.wantPermType, out) + } + }) + } +} + +func TestDrive_MemberAddDryRunRejectsInvalidInputs(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "unsupported URL host", + args: []string{ + "drive", "+member-add", + "--token", "https://google.com/docx/doxcnE2E001", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "unsupported URL host", + }, + { + name: "unsupported URL path", + args: []string{ + "drive", "+member-add", + "--token", "https://example.feishu.cn/calendar/calE2E001", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "unsupported URL path", + }, + { + name: "unknown bare token prefix", + args: []string{ + "drive", "+member-add", + "--token", "unknownE2E001", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "could not infer resource type from bare token", + }, + { + name: "member-id prefix conflicts with explicit member-type", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--member-id", "ou_e2e_user,oc_e2e_chat", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "implies --member-type openchat", + }, + { + name: "explicit member-type conflicts with member-id prefix", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--member-id", "oc_e2e_chat", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "implies --member-type openchat", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + require.Contains(t, result.Stderr, tt.wantErr, "stderr:\n%s", result.Stderr) + }) + } +}