mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
feat(drive): add +member-add shortcut with wiki space member collection collaborator support (#1204)
This commit is contained in:
committed by
GitHub
parent
2e3073a532
commit
af9835c288
686
shortcuts/drive/drive_member_add.go
Normal file
686
shortcuts/drive/drive_member_add.go
Normal file
@@ -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
|
||||
}
|
||||
1507
shortcuts/drive/drive_member_add_test.go
Normal file
1507
shortcuts/drive/drive_member_add_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveSync,
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -33,6 +33,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+sync",
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -144,6 +144,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [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 文件或文档的密级标签。 |
|
||||
|
||||
|
||||
66
skills/lark-drive/references/lark-drive-member-add.md
Normal file
66
skills/lark-drive/references/lark-drive-member-add.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# drive +member-add(添加协作者/授权成员权限)
|
||||
|
||||
> 这是高风险写操作。真实执行会修改文档权限,需要显式加 `--yes`
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
|
||||
# 批量添加(同一 member-type 和 perm,最多 10 人)
|
||||
lark-cli drive +member-add \
|
||||
--token "<bare_token_or_url>" \
|
||||
--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`。
|
||||
499
tests/cli_e2e/drive/drive_member_add_dryrun_test.go
Normal file
499
tests/cli_e2e/drive/drive_member_add_dryrun_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user