diff --git a/shortcuts/drive/drive_member_add.go b/shortcuts/drive/drive_member_add.go new file mode 100644 index 000000000..69e89772f --- /dev/null +++ b/shortcuts/drive/drive_member_add.go @@ -0,0 +1,686 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// 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", "wikispaceid", +} + +var driveMemberAddPerms = []string{"view", "edit", "full_access"} +var driveMemberAddPermTypes = []string{"container", "single_page"} +var driveMemberAddWikiSpaceMemberKinds = []string{"wiki_space_member", "wiki_space_viewer", "wiki_space_editor"} + +// 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", +} + +var driveMemberAddURLPathToType = []struct { + Prefix string + Type string +}{ + {"/drive/folder/", "folder"}, + {"/docx/", "docx"}, + {"/doc/", "doc"}, + {"/sheets/", "sheet"}, + {"/base/", "bitable"}, + {"/bitable/", "bitable"}, + {"/wiki/", "wiki"}, + {"/file/", "file"}, + {"/mindnotes/", "mindnote"}, + {"/slides/", "slides"}, + {"/minutes/", "minutes"}, +} + +var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes"} + +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; type is auto-inferred from URL path when omitted", Required: true}, + {Name: "type", Desc: "target resource type; required when --token is a bare token"}, + {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|wikispaceid", Required: true}, + {Name: "member-kind", Desc: "request body type when --member-type=wikispaceid; one of wiki_space_member|wiki_space_viewer|wiki_space_editor"}, + {Name: "perm", Desc: "permission role to grant; defaults to view"}, + {Name: "perm-type", Desc: "wiki permission scope; defaults to container; rejected for non-wiki types"}, + {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 URL paths; pass --type when --token is a bare token.", + "Supported --member-type values: email, openid, unionid, openchat, opendepartmentid, groupid, appid, wikispaceid.", + "When --member-type=wikispaceid, pass --member-kind wiki_space_member, wiki_space_viewer, or wiki_space_editor.", + "--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 and sub-pages), except --member-type=wikispaceid where --member-kind provides the wiki-space role.", + "Department collaborator (--member-type=opendepartmentid) requires --as user; bot identity is not supported for department authorization.", + }, + 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 + } + + if len(spec.MemberIDs) == 1 { + return executeDriveMemberAddSingle(runtime, spec) + } + return executeDriveMemberAddBatch(runtime, spec) + }, +} + +// 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 + // MemberKind is the explicit --member-kind value for member_type=wikispaceid. + MemberKind 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 query params for permission.members.create. +func (spec driveMemberAddSpec) APIQueryParams() map[string]interface{} { + params := map[string]interface{}{"type": spec.ResourceType} + if spec.NotificationSet { + params["need_notification"] = strconv.FormatBool(spec.NeedNotification) + } + return params +} + +// buildMemberBody builds a single member object for the request body. +func buildMemberBody(memberID, memberType, wikiSpaceMemberKind, perm, permType string) map[string]interface{} { + body := map[string]interface{}{ + "member_id": memberID, + "member_type": memberType, + "perm": perm, + } + if bodyType := driveMemberAddBodyType(memberType, wikiSpaceMemberKind); bodyType != "" { + body["type"] = bodyType + } + 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"), runtime.Str("type")) + if err != nil { + return driveMemberAddSpec{}, err + } + + // Parse member-id: comma-separated for batch. + rawMemberID := strings.TrimSpace(runtime.Str("member-id")) + if rawMemberID == "" { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id") + } + memberIDs := splitAndTrimMembers(rawMemberID) + if len(memberIDs) == 0 { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and must contain at least one non-blank ID").WithParam("--member-id") + } + if len(memberIDs) > driveMemberAddBatchLimit { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id accepts at most %d IDs, got %d", driveMemberAddBatchLimit, len(memberIDs)).WithParam("--member-id") + } + if duplicate, first, second, ok := firstDuplicateDriveMemberID(memberIDs); ok { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, + "--member-id contains duplicate collaborator ID %q at positions %d and %d; remove duplicates before retrying", + duplicate, first+1, second+1, + ).WithParam("--member-id") + } + + memberType, err := resolveDriveMemberAddMemberType(memberIDs, runtime.Str("member-type")) + if err != nil { + return driveMemberAddSpec{}, err + } + memberKind, err := resolveDriveMemberAddMemberKind(memberType, runtime.Str("member-kind")) + if err != nil { + return driveMemberAddSpec{}, err + } + + // perm: default to view. + perm, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm"), driveMemberAddPerms, "--perm") + if err != nil { + return driveMemberAddSpec{}, err + } + if perm == "" { + perm = "view" + } + + // perm-type: only meaningful for wiki; default container except for wiki-space collaborators. + permType, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm-type"), driveMemberAddPermTypes, "--perm-type") + if err != nil { + return driveMemberAddSpec{}, err + } + if resourceType == "wiki" && memberType == "wikispaceid" { + if runtime.Changed("perm-type") { + return driveMemberAddSpec{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--perm-type is not supported when --member-type=wikispaceid; use --member-kind wiki_space_member|wiki_space_viewer|wiki_space_editor to set the wiki-space role", + ).WithParam("--perm-type") + } + permType = "" + } else if resourceType == "wiki" && permType == "" { + permType = driveMemberAddDefaultPermType(resourceType) + } else if resourceType != "wiki" && runtime.Changed("perm-type") { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type") + } else if resourceType != "wiki" { + permType = "" + } + + spec := driveMemberAddSpec{ + Token: token, + ResourceType: resourceType, + MemberIDs: memberIDs, + MemberType: memberType, + MemberKind: memberKind, + Perm: perm, + PermType: permType, + NeedNotification: runtime.Bool("need-notification"), + NotificationSet: runtime.Changed("need-notification"), + } + if runtime.As().IsBot() && spec.NotificationSet { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--need-notification is only valid with --as user; omit it when using --as bot").WithParam("--need-notification") + } + if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" { + return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-type=opendepartmentid requires --as user; bot identity does not support adding department collaborators").WithParam("--member-type") + } + 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, plus an +// optional explicit --type. Explicit --type wins over URL inference. +func resolveDriveMemberAddTarget(raw, explicitType string) (token, resourceType string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token") + } + explicitType = strings.ToLower(strings.TrimSpace(explicitType)) + + if strings.Contains(raw, "://") { + parsed, parseErr := url.Parse(raw) + if parseErr != nil || parsed.Hostname() == "" { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token") + } + urlToken, urlType, ok := parseDriveMemberAddResourceURLPath(parsed.Path) + if !ok { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "unsupported URL path %q: expected one of %s followed by a token", + parsed.Path, strings.Join(driveMemberAddSupportedURLPaths(), ", "), + ).WithParam("--token") + } + token = urlToken + if explicitType == "" { + resourceType = urlType + } + } else { + token = raw + } + + if explicitType != "" { + if !isSupportedDriveMemberAddResourceType(explicitType) { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--type must be one of: %s", strings.Join(driveMemberAddResourceTypes, ", ")).WithParam("--type") + } + resourceType = explicitType + } + + if resourceType == "" { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "--type is required when --token is a bare token; accepted values: %s", + strings.Join(driveMemberAddResourceTypes, ", "), + ).WithParam("--type") + } + return token, resourceType, nil +} + +func driveMemberAddSupportedURLPaths() []string { + paths := make([]string, 0, len(driveMemberAddURLPathToType)) + for _, mapping := range driveMemberAddURLPathToType { + paths = append(paths, mapping.Prefix) + } + return paths +} + +func parseDriveMemberAddResourceURLPath(path string) (token, resourceType string, ok bool) { + for _, mapping := range driveMemberAddURLPathToType { + if !strings.HasPrefix(path, mapping.Prefix) { + continue + } + token := path[len(mapping.Prefix):] + token = strings.TrimRight(token, "/") + if idx := strings.IndexByte(token, '/'); idx >= 0 { + token = token[:idx] + } + token = strings.TrimSpace(token) + if token == "" { + return "", "", false + } + return token, mapping.Type, true + } + return "", "", false +} + +func isSupportedDriveMemberAddResourceType(resourceType string) bool { + switch resourceType { + case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes": + return true + default: + return false + } +} + +func resolveDriveMemberAddMemberType(memberIDs []string, explicit string) (string, error) { + var err error + explicit, err = normalizeDriveMemberAddEnumValue(explicit, driveMemberAddIDTypes, "--member-type") + if err != nil { + return "", err + } + if explicit == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-type is required; accepted values: %s", strings.Join(driveMemberAddIDTypes, ", ")).WithParam("--member-type") + } + for i, memberID := range memberIDs { + if expected := inferMemberTypeFromID(memberID); expected != "" && expected != explicit { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "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, + ).WithParam("--member-id") + } + } + return normalizeDriveMemberAddMemberType(explicit), nil +} + +func resolveDriveMemberAddMemberKind(memberType, raw string) (string, error) { + memberKind, err := normalizeDriveMemberAddEnumValue(raw, driveMemberAddWikiSpaceMemberKinds, "--member-kind") + if err != nil { + return "", err + } + if memberType == "wikispaceid" { + if memberKind == "" { + return "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--member-kind is required when --member-type=wikispaceid; allowed: %s", + strings.Join(driveMemberAddWikiSpaceMemberKinds, ", "), + ).WithParam("--member-kind") + } + return memberKind, nil + } + if memberKind != "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-kind only applies when --member-type=wikispaceid").WithParam("--member-kind") + } + return "", nil +} + +func normalizeDriveMemberAddMemberType(memberType string) string { + return strings.ToLower(strings.TrimSpace(memberType)) +} + +func normalizeDriveMemberAddEnumValue(raw string, allowed []string, flagName string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", nil + } + for _, candidate := range allowed { + if strings.EqualFold(value, candidate) { + return candidate, nil + } + } + return "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "invalid value %q for %s, allowed: %s", + value, + flagName, + strings.Join(allowed, ", "), + ).WithParam(flagName) +} + +// 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 +} + +func firstDuplicateDriveMemberID(memberIDs []string) (duplicate string, first, second int, ok bool) { + seen := make(map[string]int, len(memberIDs)) + for i, memberID := range memberIDs { + if prev, exists := seen[memberID]; exists { + return memberID, prev, i, true + } + seen[memberID] = i + } + return "", 0, 0, false +} + +// 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 "" +} + +// driveMemberAddDefaultPermType returns the default perm_type for a given +// resource type. For wiki nodes, container is the default for regular +// collaborators. Wiki-space collaborators omit perm_type because their role is +// carried by the body type field. +func driveMemberAddDefaultPermType(resourceType string) string { + switch resourceType { + case "wiki": + return "container" + default: + 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 "" + } +} + +func driveMemberAddBodyType(memberType, wikiSpaceMemberKind string) string { + if memberType == "wikispaceid" { + return wikiSpaceMemberKind + } + return inferDriveMemberKind(memberType) +} + +// buildDriveMemberAddDryRun renders the exact request preview for --dry-run. +func buildDriveMemberAddDryRun(spec driveMemberAddSpec) *common.DryRunAPI { + if len(spec.MemberIDs) == 1 { + body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.MemberKind, spec.Perm, 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) + } + + members := buildDriveMemberAddMemberBodies(spec) + 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) error { + fmt.Fprintf(runtime.IO().ErrOut, "Adding Drive member %s (type=%s, perm=%s) to %s %s...\n", + common.MaskToken(spec.MemberIDs[0]), spec.MemberType, spec.Perm, spec.ResourceType, common.MaskToken(spec.Token)) + + body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType) + data, err := runtime.CallAPITyped( + "POST", + 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. A successful HTTP/API +// response is treated as complete only when the server returns every requested +// member_id, regardless of response array order. +func executeDriveMemberAddBatch(runtime *common.RuntimeContext, spec driveMemberAddSpec) error { + members := buildDriveMemberAddMemberBodies(spec) + + fmt.Fprintf(runtime.IO().ErrOut, "Adding %d Drive members (type=%s, perm=%s) to %s %s...\n", + len(spec.MemberIDs), spec.MemberType, spec.Perm, spec.ResourceType, common.MaskToken(spec.Token)) + + data, err := runtime.CallAPITyped( + "POST", + 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 wrapDriveMemberAddBatchAPIError(err) + } + + result := buildDriveMemberAddBatchResult(spec, data) + if common.GetBool(result, "partial") { + return runtime.OutPartialFailure(result, nil) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Added %d Drive member(s)\n", result["succeeded_count"]) + runtime.Out(result, nil) + return nil +} + +const ( + driveMemberAddInvalidParameterCode = 1063001 + driveMemberAddInvalidOperationCode = 1063003 +) + +func wrapDriveMemberAddBatchAPIError(err error) error { + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + return err + } + + wrapped := *apiErr + switch apiErr.Code { + case driveMemberAddInvalidOperationCode: + wrapped.Message = "Drive batch member add failed: one or more requested members may already be collaborators on this resource" + wrapped.Hint = "For batch add, remove members that already have access (especially a bot/app being added again), then retry only the missing collaborators." + case driveMemberAddInvalidParameterCode: + wrapped.Message = "Drive batch member add failed: one or more requested members may be invalid for this resource or identity" + wrapped.Hint = "Check whether each --member-id exists, belongs to the same tenant, and is visible to the current identity; remove invalid members and retry only the valid collaborators." + default: + return err + } + wrapped.Cause = err + return &wrapped +} + +func buildDriveMemberAddMemberBodies(spec driveMemberAddSpec) []map[string]interface{} { + members := make([]map[string]interface{}, len(spec.MemberIDs)) + for i, mid := range spec.MemberIDs { + members[i] = buildMemberBody(mid, spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType) + } + return members +} + +func buildDriveMemberAddBatchResult(spec driveMemberAddSpec, data map[string]interface{}) map[string]interface{} { + rawMembers, _ := data["members"].([]interface{}) + + // Build set of requested IDs for O(1) lookup. + requestedSet := make(map[string]bool, len(spec.MemberIDs)) + for _, id := range spec.MemberIDs { + requestedSet[id] = true + } + + // First pass: build returned map and results array. + // Matching is done by member_id, not by array index, so the server may + // return members in any order without causing false partial_failure. + results := make([]map[string]interface{}, 0, len(rawMembers)) + succeededIDs := make(map[string]bool, len(rawMembers)) + var mismatched []map[string]interface{} + + for _, raw := range rawMembers { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + rawMemberID := common.GetString(m, "member_id") + + out := driveMemberAddOutputWithOptions(spec, "", m, false) + results = append(results, out) + + if rawMemberID != "" { + if requestedSet[rawMemberID] { + succeededIDs[rawMemberID] = true + } else { + mismatched = append(mismatched, map[string]interface{}{ + "returned": rawMemberID, + }) + } + } + } + + // Second pass: find requested IDs missing from the response. + missing := make([]string, 0) + for _, memberID := range spec.MemberIDs { + if !succeededIDs[memberID] { + missing = append(missing, memberID) + } + } + + partial := len(results) != len(spec.MemberIDs) || len(missing) > 0 || len(mismatched) > 0 + result := map[string]interface{}{ + "resource_token": spec.Token, + "resource_type": spec.ResourceType, + "requested_count": len(spec.MemberIDs), + "succeeded_count": len(succeededIDs), + "partial": partial, + "members": results, + "missing_member_ids": missing, + } + if len(mismatched) > 0 { + result["mismatched_member_ids"] = mismatched + } + return result +} + +// 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{} { + return driveMemberAddOutputWithOptions(spec, fallbackMemberID, raw, true) +} + +func driveMemberAddOutputWithOptions(spec driveMemberAddSpec, fallbackMemberID string, raw map[string]interface{}, allowDefaultMemberID bool) 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", "type"} { + if v, ok := raw[key]; ok { + out[key] = v + } + } + if spec.ResourceType == "wiki" { + if v, ok := raw["perm_type"]; ok { + out["perm_type"] = v + } + } + } + if common.GetString(out, "member_id") == "" { + if fallbackMemberID == "" && allowDefaultMemberID && len(spec.MemberIDs) > 0 { + fallbackMemberID = spec.MemberIDs[0] + } + if fallbackMemberID != "" { + out["member_id"] = fallbackMemberID + } + } + if common.GetString(out, "member_type") == "" { + out["member_type"] = spec.MemberType + } + if common.GetString(out, "perm") == "" { + out["perm"] = spec.Perm + } + if spec.PermType != "" && common.GetString(out, "perm_type") == "" { + out["perm_type"] = spec.PermType + } + if bodyType := driveMemberAddBodyType(spec.MemberType, spec.MemberKind); bodyType != "" && common.GetString(out, "type") == "" { + out["type"] = bodyType + } + 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..662780444 --- /dev/null +++ b/shortcuts/drive/drive_member_add_test.go @@ -0,0 +1,1507 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// ── resolveDriveMemberAddTarget unit tests ────────────────────────────────── + +func TestResolveDriveMemberAddTarget_URLAndBareToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + explicit 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"}, + {"wiki URL", "https://example.feishu.cn/wiki/wikTok", "", "wikTok", "wiki"}, + {"mindnotes URL", "https://example.feishu.cn/mindnotes/mndTok", "", "mndTok", "mindnote"}, + {"larkoffice URL", "https://tenant.larkoffice.com/docx/doxTok", "", "doxTok", "docx"}, + {"explicit type overrides URL", "https://example.feishu.cn/docx/doxTok", "wiki", "doxTok", "wiki"}, + {"bare token with explicit docx type", "N83ZduEnHooFswxnVWGcazlLnFf", "docx", "N83ZduEnHooFswxnVWGcazlLnFf", "docx"}, + {"bare token with explicit folder type", "fldToken123", "folder", "fldToken123", "folder"}, + } + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + token, resourceType, err := resolveDriveMemberAddTarget(tt.raw, tt.explicit) + 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_RejectsBareTokenWithoutType(t *testing.T) { + t.Parallel() + + _, _, err := resolveDriveMemberAddTarget("N83ZduEnHooFswxnVWGcazlLnFf", "") + if err == nil || !strings.Contains(err.Error(), "--type is required when --token is a bare token") { + t.Fatalf("expected bare token type-required error, got: %v", err) + } +} + +func TestResolveDriveMemberAddTarget_AcceptsAnyHost(t *testing.T) { + t.Parallel() + + token, resourceType, err := resolveDriveMemberAddTarget("https://google.com/docx/doxTok", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "doxTok" { + t.Fatalf("expected token 'doxTok', got %q", token) + } + if resourceType != "docx" { + t.Fatalf("expected resourceType 'docx', got %q", resourceType) + } +} + +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_AcceptsMinutesURL(t *testing.T) { + t.Parallel() + + token, resourceType, err := resolveDriveMemberAddTarget("https://example.feishu.cn/minutes/obcnTok", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "obcnTok" { + t.Fatalf("expected token 'obcnTok', got %q", token) + } + if resourceType != "minutes" { + t.Fatalf("expected resourceType 'minutes', got %q", resourceType) + } +} + +func TestResolveDriveMemberAddTarget_RejectsInvalidExplicitType(t *testing.T) { + t.Parallel() + + _, _, err := resolveDriveMemberAddTarget("mincnTok", "invalidtype") + if err == nil || !strings.Contains(err.Error(), "--type must be one of") { + t.Fatalf("expected invalid type 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: "explicit openchat", + memberIDs: []string{"oc_a"}, + explicit: "openchat", + wantType: "openchat", + }, + { + name: "explicit groupid", + memberIDs: []string{"group_1"}, + explicit: "groupid", + wantType: "groupid", + }, + { + name: "explicit appid", + memberIDs: []string{"cli_xxx"}, + explicit: "appid", + wantType: "appid", + }, + { + name: "explicit wikispaceid", + memberIDs: []string{"space_xxx"}, + explicit: "wikispaceid", + wantType: "wikispaceid", + }, + { + 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 TestResolveDriveMemberAddMemberKind(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + memberType string + raw string + want string + wantErr string + }{ + { + name: "wikispaceid requires explicit wiki-space kind", + memberType: "wikispaceid", + wantErr: "--member-kind is required when --member-type=wikispaceid", + }, + { + name: "wikispaceid accepts member kind", + memberType: "wikispaceid", + raw: "wiki_space_member", + want: "wiki_space_member", + }, + { + name: "wikispaceid accepts uppercase member kind", + memberType: "wikispaceid", + raw: "WIKI_SPACE_EDITOR", + want: "wiki_space_editor", + }, + { + name: "reject invalid member kind", + memberType: "wikispaceid", + raw: "user", + wantErr: "invalid value \"user\" for --member-kind", + }, + { + name: "reject member kind for other member type", + memberType: "openid", + raw: "wiki_space_viewer", + wantErr: "--member-kind only applies when --member-type=wikispaceid", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := resolveDriveMemberAddMemberKind(tt.memberType, tt.raw) + 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 got != tt.want { + t.Fatalf("memberKind = %q, want %q", got, tt.want) + } + }) + } +} + +func TestNormalizeDriveMemberAddMemberType(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + want string + }{ + {"openid", "openid"}, + {"groupid", "groupid"}, + {"appid", "appid"}, + {"wikispaceid", "wikispaceid"}, + } + for _, tt := range tests { + if got := normalizeDriveMemberAddMemberType(tt.in); got != tt.want { + t.Fatalf("normalizeDriveMemberAddMemberType(%q) = %q, want %q", tt.in, 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 := spec.APIQueryParams(); !reflect.DeepEqual(got, map[string]interface{}{"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", ""); !reflect.DeepEqual(got, wantBody) { + t.Fatalf("buildMemberBody() = %#v, want %#v", got, wantBody) + } +} + +func TestDriveMemberAddSpec_BuildsWikiSpaceIDBody(t *testing.T) { + t.Parallel() + + wantBody := map[string]interface{}{ + "member_id": "spc_x", + "member_type": "wikispaceid", + "perm": "view", + "type": "wiki_space_editor", + } + if got := buildMemberBody("spc_x", "wikispaceid", "wiki_space_editor", "view", ""); !reflect.DeepEqual(got, wantBody) { + t.Fatalf("buildMemberBody() = %#v, want %#v", got, wantBody) + } +} + +func TestDriveMemberAddBodyType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + memberType string + wikiSpaceMemberKind string + want string + }{ + {name: "regular user infers body type", memberType: "openid", want: "user"}, + {name: "regular group infers body type", memberType: "groupid", want: "group"}, + {name: "wiki space uses explicit member kind", memberType: "wikispaceid", wikiSpaceMemberKind: "wiki_space_viewer", want: "wiki_space_viewer"}, + {name: "wiki space does not infer fallback type", memberType: "wikispaceid", want: ""}, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := driveMemberAddBodyType(tt.memberType, tt.wikiSpaceMemberKind); got != tt.want { + t.Fatalf("driveMemberAddBodyType() = %q, want %q", got, tt.want) + } + }) + } +} + +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 := spec.APIQueryParams(); !reflect.DeepEqual(got, map[string]interface{}{"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_BackfillsAppIDMemberType(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + Token: "doxTok", + ResourceType: "docx", + MemberIDs: []string{"cli_app_123"}, + MemberType: "appid", + Perm: "view", + } + out := driveMemberAddOutput(spec, "cli_app_123", map[string]interface{}{"perm": "view"}) + if out["member_type"] != "appid" { + t.Fatalf("member_type = %v, want appid", out["member_type"]) + } +} + +func TestDriveMemberAddOutput_OmitsPermTypeForNonWiki(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + Token: "doxTok", + ResourceType: "docx", + MemberIDs: []string{"ou_x"}, + MemberType: "openid", + Perm: "view", + } + out := driveMemberAddOutput(spec, "ou_x", map[string]interface{}{ + "member_id": "ou_x", + "member_type": "openid", + "perm": "view", + "perm_type": "container", + "type": "user", + }) + if _, ok := out["perm_type"]; ok { + t.Fatalf("perm_type should be omitted for non-wiki output, got %#v", out["perm_type"]) + } +} + +func TestBuildDriveMemberAddBatchResult_OmitsPermTypeForNonWiki(t *testing.T) { + t.Parallel() + + spec := driveMemberAddSpec{ + Token: "doxTok", + ResourceType: "docx", + MemberIDs: []string{"ou_a", "ou_b"}, + MemberType: "openid", + Perm: "view", + } + result := buildDriveMemberAddBatchResult(spec, map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "perm_type": "container", "type": "user"}, + map[string]interface{}{"member_id": "ou_b", "member_type": "openid", "perm": "view", "perm_type": "container", "type": "user"}, + }, + }) + members, ok := result["members"].([]map[string]interface{}) + if !ok { + t.Fatalf("members = %#v, want []map[string]interface{}", result["members"]) + } + for i, member := range members { + if _, exists := member["perm_type"]; exists { + t.Fatalf("members[%d].perm_type should be omitted for non-wiki output, got %#v", i, member["perm_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", + "--type", "docx", + "--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", + "--type", "docx", + "--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_RejectsDepartmentWithBot(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", + "--type", "docx", + "--member-id", "od_dept", + "--member-type", "opendepartmentid", + "--perm", "view", + "--as", "bot", + "--yes", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--member-type=opendepartmentid requires --as user") { + t.Fatalf("expected bot+opendepartmentid 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", + "--type", "docx", + "--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_DryRunAcceptsAppID(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", + "--type", "docx", + "--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"] != "appid" { + t.Fatalf("member_type = %v, want appid", got.API[0].Body["member_type"]) + } + if _, ok := got.API[0].Body["type"]; ok { + t.Fatalf("type = %v, want omitted for appid", got.API[0].Body["type"]) + } +} + +func TestDriveMemberAdd_DryRunAcceptsWikiSpaceID(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", + "--type", "docx", + "--member-id", "spc_x", + "--member-type", "wikispaceid", + "--member-kind", "wiki_space_viewer", + "--perm", "view", + "--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["member_type"] != "wikispaceid" || got.API[0].Body["type"] != "wiki_space_viewer" { + t.Fatalf("body = %#v, want wikispaceid + wiki_space_viewer", got.API[0].Body) + } +} + +func TestDriveMemberAdd_RejectsWikiSpaceIDWithoutMemberKind(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", + "--type", "docx", + "--member-id", "spc_x", + "--member-type", "wikispaceid", + "--perm", "view", + "--dry-run", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--member-kind is required when --member-type=wikispaceid") { + t.Fatalf("expected wikispaceid member-kind validation error, got: %v", err) + } +} + +func TestDriveMemberAdd_RejectsMemberKindForNonWikiSpaceID(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", + "--type", "docx", + "--member-id", "ou_x", + "--member-type", "openid", + "--member-kind", "wiki_space_member", + "--perm", "view", + "--dry-run", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--member-kind only applies when --member-type=wikispaceid") { + t.Fatalf("expected non-wikispaceid member-kind validation error, got: %v", err) + } +} + +func TestDriveMemberAdd_RejectsBlankMemberIDList(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", + "--type", "docx", + "--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", + "--type", "docx", + "--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_RejectsDuplicateBatchMemberIDs(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", + "--type", "docx", + "--member-id", "ou_a,ou_b,ou_a", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "duplicate collaborator ID") { + t.Fatalf("expected duplicate member-id validation 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_DryRunAcceptsUppercaseEnumsForDocx(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", + "--type", "DOCX", + "--member-id", "ou_x", + "--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 { + 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 got.API[0].Params["type"] != "docx" { + t.Fatalf("params.type = %v, want docx", got.API[0].Params["type"]) + } + if got.API[0].Body["member_type"] != "openid" || got.API[0].Body["perm"] != "edit" { + t.Fatalf("body = %#v, want canonical lowercase enum values", got.API[0].Body) + } +} + +func TestDriveMemberAdd_DryRunAcceptsUppercaseWikiPermType(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", "wikcnTok", + "--type", "WIKI", + "--member-id", "ou_x", + "--member-type", "OPENID", + "--perm", "EDIT", + "--perm-type", "CONTAINER", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + API []struct { + 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 got.API[0].Params["type"] != "wiki" { + t.Fatalf("params.type = %v, want wiki", got.API[0].Params["type"]) + } + if got.API[0].Body["member_type"] != "openid" || got.API[0].Body["perm"] != "edit" || got.API[0].Body["perm_type"] != "container" { + t.Fatalf("body = %#v, want canonical lowercase enum values", got.API[0].Body) + } +} + +func TestDriveMemberAdd_RejectsInvalidPermLocallyWithoutGlobalEnum(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", + "--type", "DOCX", + "--member-id", "ou_x", + "--member-type", "OPENID", + "--perm", "INVALID_EDIT", + "--dry-run", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "invalid value \"INVALID_EDIT\" for --perm") { + t.Fatalf("expected local invalid --perm validation error, got: %v", err) + } +} + +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", + "--type", "docx", + "--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", + "--type", "sheet", + "--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", + "--type", "docx", + "--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_ExecuteBatchSuccess(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/bascnTok/members/batch_create", + OnMatch: func(req *http.Request) { + capturedQuery = req.URL.RawQuery + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "type": "user"}, + map[string]interface{}{"member_id": "ou_b", "member_type": "openid", "perm": "view", "type": "user"}, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if data["requested_count"] != float64(2) || data["succeeded_count"] != float64(2) || data["partial"] != false { + t.Fatalf("batch output counts = %#v", data) + } + missing, ok := data["missing_member_ids"].([]interface{}) + if !ok || len(missing) != 0 { + t.Fatalf("missing_member_ids = %#v, want empty array", data["missing_member_ids"]) + } + members, ok := data["members"].([]interface{}) + if !ok || len(members) != 2 { + t.Fatalf("members = %#v, want 2", data["members"]) + } + first, _ := members[0].(map[string]interface{}) + second, _ := members[1].(map[string]interface{}) + if first["member_id"] != "ou_a" || second["member_id"] != "ou_b" { + t.Fatalf("members = %#v, want request-order fallback IDs", members) + } + if !strings.Contains(stderr.String(), "Added 2 Drive member(s)") { + t.Fatalf("stderr = %q, want success log", stderr.String()) + } + if !strings.Contains(capturedQuery, "type=bitable") { + t.Fatalf("captured query = %q, want type=bitable for bascn token", capturedQuery) + } +} + +func TestDriveMemberAdd_ExecuteBatchSuccessWithOutOfOrderResponse(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/bascnTok/members/batch_create", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"member_id": "ou_b", "member_type": "openid", "perm": "view", "type": "user"}, + map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "type": "user"}, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDriveEnvelope(t, stdout) + if data["requested_count"] != float64(2) || data["succeeded_count"] != float64(2) || data["partial"] != false { + t.Fatalf("batch output counts = %#v", data) + } + missing, ok := data["missing_member_ids"].([]interface{}) + if !ok || len(missing) != 0 { + t.Fatalf("missing_member_ids = %#v, want empty array", data["missing_member_ids"]) + } + if _, ok := data["mismatched_member_ids"]; ok { + t.Fatalf("mismatched_member_ids should not be present on success: %#v", data["mismatched_member_ids"]) + } + members, ok := data["members"].([]interface{}) + if !ok || len(members) != 2 { + t.Fatalf("members = %#v, want 2", data["members"]) + } +} + +func TestDriveMemberAdd_ExecuteBatchPartialWhenResponseMissingMember(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/bascnTok/members/batch_create", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "type": "user"}, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + exitErr := assertDriveMemberAddPartialFailure(t, err) + if exitErr.Code != output.ExitAPI { + t.Fatalf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI) + } + + // stdout must carry ok:false envelope with structured partial result. + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String()) + } + if env.OK { + t.Fatalf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String()) + } + if v := common.GetInt(env.Data, "succeeded_count"); v != 1 { + t.Fatalf("succeeded_count = %d, want 1", v) + } + if v := common.GetInt(env.Data, "requested_count"); v != 2 { + t.Fatalf("requested_count = %d, want 2", v) + } + if v := common.GetBool(env.Data, "partial"); !v { + t.Fatal("partial must be true") + } +} + +func TestDriveMemberAdd_ExecuteBatchPartialDoesNotInferMissingMemberID(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/bascnTok/members/batch_create", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"member_type": "openid", "perm": "view", "type": "user"}, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + exitErr := assertDriveMemberAddPartialFailure(t, err) + if exitErr.Code != output.ExitAPI { + t.Fatalf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI) + } + + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String()) + } + if env.OK { + t.Fatalf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String()) + } + if v := common.GetInt(env.Data, "succeeded_count"); v != 0 { + t.Fatalf("succeeded_count = %d, want 0", v) + } + if v := common.GetInt(env.Data, "requested_count"); v != 2 { + t.Fatalf("requested_count = %d, want 2", v) + } + if v := common.GetBool(env.Data, "partial"); !v { + t.Fatal("partial must be true") + } +} + +func TestDriveMemberAdd_ExecuteBatchPartialWhenMemberIDMismatches(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/bascnTok/members/batch_create", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"member_id": "ou_a", "member_type": "openid", "perm": "view", "type": "user"}, + map[string]interface{}{"member_id": "ou_other", "member_type": "openid", "perm": "view", "type": "user"}, + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + exitErr := assertDriveMemberAddPartialFailure(t, err) + if exitErr.Code != output.ExitAPI { + t.Fatalf("exit code = %d, want %d (ExitAPI)", exitErr.Code, output.ExitAPI) + } + + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String()) + } + if env.OK { + t.Fatalf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String()) + } + if v := common.GetBool(env.Data, "partial"); !v { + t.Fatal("partial must be true") + } + missing, _ := env.Data["missing_member_ids"].([]interface{}) + if len(missing) != 1 || missing[0] != "ou_b" { + t.Fatalf("missing_member_ids = %#v, want [ou_b]", env.Data["missing_member_ids"]) + } + mismatched, _ := env.Data["mismatched_member_ids"].([]interface{}) + if len(mismatched) != 1 { + t.Fatalf("mismatched_member_ids = %#v, want 1 entry", env.Data["mismatched_member_ids"]) + } + mismatchedEntry, _ := mismatched[0].(map[string]interface{}) + if mismatchedEntry["returned"] != "ou_other" { + t.Fatalf("mismatched_member_ids[0].returned = %#v, want ou_other", mismatchedEntry["returned"]) + } +} + +func TestDriveMemberAdd_ExecuteBatchInvalidOperationHasActionableHint(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/bascnTok/members/batch_create", + Body: map[string]interface{}{ + "code": 1063003, + "msg": "Invalid operation", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_b", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected API error, got nil") + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *errs.APIError, got %T: %v", err, err) + } + if apiErr.Code != 1063003 { + t.Fatalf("code = %d, want 1063003", apiErr.Code) + } + if !strings.Contains(apiErr.Message, "requested members may already be collaborators") { + t.Fatalf("message = %q, want duplicate-permission guidance", apiErr.Message) + } + if !strings.Contains(apiErr.Hint, "retry only the missing collaborators") { + t.Fatalf("hint = %q, want retry guidance", apiErr.Hint) + } +} + +func TestDriveMemberAdd_ExecuteBatchInvalidParameterHasConservativeHint(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/bascnTok/members/batch_create", + Body: map[string]interface{}{ + "code": 1063001, + "msg": "Invalid parameter", + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "bascnTok", + "--type", "bitable", + "--member-id", "ou_a,ou_missing", + "--member-type", "openid", + "--perm", "view", + "--as", "user", + "--yes", + }, f, stdout) + if err == nil { + t.Fatal("expected API error, got nil") + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *errs.APIError, got %T: %v", err, err) + } + if apiErr.Code != 1063001 { + t.Fatalf("code = %d, want 1063001", apiErr.Code) + } + if !strings.Contains(apiErr.Message, "requested members may be invalid") { + t.Fatalf("message = %q, want invalid-member guidance", apiErr.Message) + } + if !strings.Contains(apiErr.Hint, "belongs to the same tenant") || !strings.Contains(apiErr.Hint, "visible to the current identity") { + t.Fatalf("hint = %q, want conservative validation guidance", apiErr.Hint) + } +} + +func TestDriveMemberAdd_ExecuteSuccessAsBot(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + var capturedQuery string + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/wikcnBotTok/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_bot_target", + "member_type": "openid", + "perm": "edit", + "type": "user", + }, + }, + }, + } + reg.Register(stub) + + err := mountAndRunDrive(t, DriveMemberAdd, []string{ + "+member-add", + "--token", "wikcnBotTok", + "--type", "wiki", + "--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. + if strings.Contains(capturedQuery, "need_notification") { + t.Fatalf("captured query = %q, want need_notification absent for bot", capturedQuery) + } + + 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 assertDriveMemberAddPartialFailure(t *testing.T, err error) *output.PartialFailureError { + t.Helper() + if err == nil { + t.Fatal("expected partial_failure error, got nil") + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) + } + return pfErr +} + +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", + "--type", "docx", + "--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 3da5fdf58..281f8b9d8 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -30,6 +30,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 a38a5c0f4..85e45ed4b 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -33,6 +33,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 0afe985cc..90491a1c6 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -144,6 +144,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+task_result`](references/lark-drive-task-result.md) | 查询 import/export/move/delete 等异步任务结果。 | | [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 | | [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 | +| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create,真实写入需要 `--yes`。 | | [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 | | [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 | 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..8b36d27eb --- /dev/null +++ b/skills/lark-drive/references/lark-drive-member-add.md @@ -0,0 +1,66 @@ +# drive +member-add(添加协作者/授权成员权限) + +> 这是高风险写操作。真实执行会修改文档权限,需要显式加 `--yes` + +## 命令 + +```bash + +# 批量添加(同一 member-type 和 perm,最多 10 人) +lark-cli drive +member-add \ + --token "" \ + --type bitable \ + --member-id "ou_a,ou_b" \ + --member-type openid \ + --perm view \ + --yes +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|----|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--token` | 是 | 裸 token 或完整 URL。路径支持 `/drive/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`;URL 输入可从路径推断 `--type`,裸 token 不做前缀推断 | +| `--type` | 必填 | 目标资源类型:`docx` / `doc` / `sheet` / `bitable` / `file` / `folder` / `wiki` / `mindnote` / `slides` / `minutes`。传 URL 时可省略;裸 token 必须显式传;若同时传 URL 和 `--type`,显式 `--type` 覆盖 URL 推断 | +| `--member-id` | 是 | 协作者 ID;逗号分隔可批量添加,最多 10 个 | +| `--member-type` | 是 | member-id 的类型;支持 `email` / `openid` / `unionid` / `openchat` / `opendepartmentid` / `groupid` / `appid` / `wikispaceid`。在实际使用里,给当前应用授权仍优先推荐 bot `open_id` + `openid`。 | +| `--member-kind` | 条件必填 | 仅当 `--member-type=wikispaceid` 时填写,映射到请求 body 的 `type` 字段。取值:`wiki_space_member` / `wiki_space_viewer` / `wiki_space_editor`。其他 member-type 禁止传此参数。 | +| `--perm` | 否 | 授权角色:`view`(默认)/ `edit` / `full_access` | +| `--perm-type` | 否 | 只作用 wiki 节点权限范围:`container`(默认,当前页面+子页面)/ `single_page`(仅当前页面) | +| `--need-notification` | 否 | 是否通知对方。仅 `--as user` 可用;未传时不会写入 query,`--need-notification=false` 表示显式不通知 | +| `--dry-run` | 否 | 仅打印请求,不实际授权 | +| `--yes` | 真实执行时是 | 确认高风险写操作 | + +## 输出 + +批量成功: + +```json +{ + "ok": true, + "identity": "user", + "data": { + "resource_token": "doc_token_or_url", + "resource_type": "docx", + "requested_count": 2, + "succeeded_count": 2, + "partial": false, + "members": [ + {"resource_token": "doc_token_or_url", "resource_type": "docx", "member_id": "ou_a", "member_type": "openid", "member_kind": "user", "perm": "view"}, + {"resource_token": "doc_token_or_url", "resource_type": "docx", "member_id": "ou_b", "member_type": "openid", "member_kind": "user", "perm": "view"} + ], + "missing_member_ids": [] + } +} +``` + +批量部分失败时,`partial` 为 `true`,CLI 以非零退出码返回 `error.type=partial_failure`。检查 `error.detail` 中的 `requested_count`、`succeeded_count`、`members`、`missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。 + +## 行为说明 + +- **身份支持**:`--as user` 和 `--as bot` 均可使用。 +- **部门协作者**:`--member-type=opendepartmentid` 必须配合 `--as user`;bot 身份不支持添加部门协作者。 +- **通知**:`--need-notification` 仅 `--as user` 时有效;`--as bot` 时传此参数会被拒绝。 +- **批量约束**:批量请求共享同一 `--member-type`、`--perm` 和 `--perm-type`;混合用户/群组/部门的场景需拆分为多次调用。 +- **Wiki 空间 ID**:`--member-type=wikispaceid` 时必须同时传 `--member-kind`,否则 API 会缺少必填的 body `type` 字段。`wiki_space_member` 对应知识库成员角色;若知识库已将成员拆分为可阅读/可编辑成员组,改用 `wiki_space_viewer` 或 `wiki_space_editor`。 +- **ID 解析**:优先用 `open_id` + `--member-type openid`;仅在无法解析 `open_id` 时使用 `email`。群组优先用 `openchat`,部门用 `opendepartmentid`。 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..3d31d18bc --- /dev/null +++ b/tests/cli_e2e/drive/drive_member_add_dryrun_test.go @@ -0,0 +1,499 @@ +// 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 locks in the request shape the shortcut emits +// under --dry-run: the real CLI binary is invoked end-to-end (so flag parsing, +// validation, and dry-run rendering all execute), and the printed request is +// inspected to confirm +// - HTTP method, URL template, and token path segment, +// - type query parameter (auto-inferred from a URL input, explicit for a +// bare token input), +// - explicit --type overriding URL inference, +// - member_type / member kind / perm / perm_type body fields, +// - single-member vs batch endpoint selection. +// +// Fake credentials are sufficient because --dry-run short-circuits before any +// network call. +func TestDrive_MemberAddDryRun(t *testing.T) { + // Isolate from any local CLI state: the subprocess inherits the parent + // test environment, and without an explicit config dir it could read a + // developer's real credentials/profile instead of the fake ones below. + 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: "URL input auto-infers docx 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: "URL input auto-infers mindnote type from mindnotes path", + args: []string{ + "drive", "+member-add", + "--token", "https://example.feishu.cn/mindnotes/mndE2E011?from=share", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/mndE2E011/members", + wantResourceType: "mindnote", + wantMemberID: "ou_e2e_user", + wantMemberType: "openid", + wantPerm: "view", + wantMemberKind: "user", + }, + { + name: "bare token with explicit wiki type defaults perm_type container", + args: []string{ + "drive", "+member-add", + "--token", "wikcnE2E002", + "--type", "wiki", + "--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", + }, + { + // Explicit --type must override URL inference: the /docx/ marker + // would infer type=docx, but the caller asked to grant permission + // against a wiki node. The URL token itself is still used as the + // path token. + name: "explicit --type overrides URL inference", + args: []string{ + "drive", "+member-add", + "--token", "https://example.feishu.cn/docx/doxcnE2E009", + "--type", "wiki", + "--member-id", "ou_e2e_override", + "--member-type", "openid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E009/members", + wantResourceType: "wiki", + wantMemberID: "ou_e2e_override", + wantMemberType: "openid", + wantPerm: "view", + wantMemberKind: "user", + wantPermType: "container", + }, + { + name: "bare token with explicit folder type", + args: []string{ + "drive", "+member-add", + "--token", "fldE2E010", + "--type", "folder", + "--member-id", "ou_e2e_folder", + "--member-type", "openid", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/fldE2E010/members", + wantResourceType: "folder", + wantMemberID: "ou_e2e_folder", + wantMemberType: "openid", + wantPerm: "view", + wantMemberKind: "user", + }, + { + name: "email member-type", + args: []string{ + "drive", "+member-add", + "--token", "shtcnE2E003", + "--type", "sheet", + "--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", + "--type", "docx", + "--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", + "--type", "docx", + "--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", + "--type", "bitable", + "--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", + "--type", "docx", + "--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 member-type is passed through", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E008", + "--type", "docx", + "--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: "appid", + wantPerm: "view", + }, + { + name: "wikispaceid member-type requires wiki-space body type", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E012", + "--type", "docx", + "--member-id", "spc_e2e_wiki", + "--member-type", "wikispaceid", + "--member-kind", "wiki_space_editor", + "--perm", "view", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxcnE2E012/members", + wantResourceType: "docx", + wantMemberID: "spc_e2e_wiki", + wantMemberType: "wikispaceid", + wantPerm: "view", + wantMemberKind: "wiki_space_editor", + }, + } + + 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) { + // Isolate from any local CLI state: the subprocess inherits the parent + // test environment, and without an explicit config dir it could read a + // developer's real credentials/profile instead of the fake ones below. + 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 + defaultAs string + wantErr string + }{ + { + name: "any host accepted", + args: []string{ + "drive", "+member-add", + "--token", "https://google.com/docx/doxcnE2E001", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--dry-run", + }, + }, + { + 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: "bare token requires explicit type", + args: []string{ + "drive", "+member-add", + "--token", "unknownE2E001", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "--type is required when --token is a bare token", + }, + { + name: "duplicate member IDs are rejected", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--type", "docx", + "--member-id", "ou_a,ou_b,ou_a", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "duplicate collaborator ID", + }, + { + name: "invalid explicit type is rejected", + args: []string{ + "drive", "+member-add", + "--token", "mincnE2E001", + "--type", "invalidtype", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "--type must be one of: docx, doc, sheet, bitable, file, folder, wiki, mindnote, slides, minutes", + }, + { + name: "member-id prefix conflicts with explicit member-type", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--type", "docx", + "--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", + "--type", "docx", + "--member-id", "oc_e2e_chat", + "--member-type", "openid", + "--dry-run", + }, + wantErr: "implies --member-type openchat", + }, + { + name: "department collaborator requires user identity", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--type", "docx", + "--member-id", "od_e2e_dept", + "--member-type", "opendepartmentid", + "--dry-run", + }, + defaultAs: "bot", + wantErr: "--member-type=opendepartmentid requires --as user", + }, + { + name: "wikispaceid requires member-kind", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--type", "docx", + "--member-id", "spc_e2e_wiki", + "--member-type", "wikispaceid", + "--dry-run", + }, + wantErr: "--member-kind is required when --member-type=wikispaceid", + }, + { + name: "member-kind only applies to wikispaceid", + args: []string{ + "drive", "+member-add", + "--token", "doxcnE2E001", + "--type", "docx", + "--member-id", "ou_e2e_user", + "--member-type", "openid", + "--member-kind", "wiki_space_member", + "--dry-run", + }, + wantErr: "--member-kind only applies when --member-type=wikispaceid", + }, + } + + 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) + defaultAs := tt.defaultAs + if defaultAs == "" { + defaultAs = "user" + } + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: defaultAs, + }) + require.NoError(t, err) + + if tt.wantErr == "" { + result.AssertExitCode(t, 0) + return + } + + result.AssertExitCode(t, 2) + output := result.Stdout + result.Stderr + require.Contains(t, output, tt.wantErr, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + }) + } +}