mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
1 Commits
codex/cli-
...
feat/drive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
162e896efc |
266
shortcuts/drive/drive_member_remove.go
Normal file
266
shortcuts/drive/drive_member_remove.go
Normal file
@@ -0,0 +1,266 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveMemberRemoveIDTypes = []string{
|
||||
"email", "openid", "openchat", "opendepartmentid",
|
||||
"userid", "unionid", "groupid", "wikispaceid",
|
||||
}
|
||||
|
||||
// DriveMemberRemove removes one collaborator/member permission from a Drive resource.
|
||||
var DriveMemberRemove = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-remove",
|
||||
Description: "Remove one collaborator/member permission from a Drive document, file, folder, or wiki node",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"docs:permission.member:delete"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
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: "single collaborator ID to remove; comma-separated values are rejected", Required: true},
|
||||
{Name: "member-type", Desc: "ID type for --member-id; supported: email|openid|openchat|opendepartmentid|userid|unionid|groupid|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-type", Desc: "wiki permission scope; defaults to container; rejected for non-wiki types and wiki-space members"},
|
||||
},
|
||||
Tips: []string{
|
||||
"This command removes exactly one collaborator; run separate commands for multiple members.",
|
||||
"Resource type is auto-inferred from URL paths; pass --type when --token is a bare token.",
|
||||
"When --member-type=wikispaceid, pass --member-kind wiki_space_member, wiki_space_viewer, or wiki_space_editor.",
|
||||
"For ordinary wiki collaborators, --perm-type defaults to container; use single_page to remove only the current-page permission.",
|
||||
"Department collaborator removal (--member-type=opendepartmentid) requires --as user; bot identity is not supported.",
|
||||
"A successful response confirms that the removal request completed; it does not prove the permission previously existed.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveMemberRemoveSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveMemberRemoveSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveMemberRemoveDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveMemberRemoveSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return executeDriveMemberRemove(runtime, spec)
|
||||
},
|
||||
}
|
||||
|
||||
type driveMemberRemoveSpec struct {
|
||||
Token string
|
||||
ResourceType string
|
||||
MemberID string
|
||||
MemberType string
|
||||
MemberKind string
|
||||
PermType string
|
||||
}
|
||||
|
||||
func (spec driveMemberRemoveSpec) APIQueryParams() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": spec.ResourceType,
|
||||
"member_type": spec.MemberType,
|
||||
}
|
||||
}
|
||||
|
||||
func (spec driveMemberRemoveSpec) Body() map[string]interface{} {
|
||||
body := make(map[string]interface{}, 2)
|
||||
if memberKind := driveMemberAddBodyType(spec.MemberType, spec.MemberKind); memberKind != "" {
|
||||
body["type"] = memberKind
|
||||
}
|
||||
if spec.PermType != "" {
|
||||
body["perm_type"] = spec.PermType
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func readDriveMemberRemoveSpec(runtime *common.RuntimeContext) (driveMemberRemoveSpec, error) {
|
||||
token, resourceType, err := resolveDriveMemberAddTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveMemberRemoveSpec{}, err
|
||||
}
|
||||
if strings.Contains(token, "/") {
|
||||
return driveMemberRemoveSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token must resolve to a single resource token and cannot contain '/'",
|
||||
).WithParam("--token")
|
||||
}
|
||||
|
||||
memberID := strings.TrimSpace(runtime.Str("member-id"))
|
||||
if memberID == "" {
|
||||
return driveMemberRemoveSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--member-id is required and cannot be blank",
|
||||
).WithParam("--member-id")
|
||||
}
|
||||
if strings.Contains(memberID, "/") {
|
||||
return driveMemberRemoveSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--member-id must be a single collaborator ID and cannot contain '/'",
|
||||
).WithParam("--member-id")
|
||||
}
|
||||
if strings.Contains(memberID, ",") {
|
||||
return driveMemberRemoveSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--member-id accepts exactly one collaborator ID; run one +member-remove command per member",
|
||||
).WithParam("--member-id")
|
||||
}
|
||||
|
||||
memberType, err := resolveDriveMemberRemoveMemberType(memberID, runtime.Str("member-type"))
|
||||
if err != nil {
|
||||
return driveMemberRemoveSpec{}, err
|
||||
}
|
||||
if memberType == "wikispaceid" && resourceType != "wiki" {
|
||||
return driveMemberRemoveSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--member-type=wikispaceid only applies when resource type is wiki; got %q",
|
||||
resourceType,
|
||||
).WithParam("--member-type")
|
||||
}
|
||||
memberKind, err := resolveDriveMemberAddMemberKind(memberType, runtime.Str("member-kind"))
|
||||
if err != nil {
|
||||
return driveMemberRemoveSpec{}, err
|
||||
}
|
||||
|
||||
permType, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm-type"), driveMemberAddPermTypes, "--perm-type")
|
||||
if err != nil {
|
||||
return driveMemberRemoveSpec{}, err
|
||||
}
|
||||
if resourceType == "wiki" && memberType == "wikispaceid" {
|
||||
if runtime.Changed("perm-type") {
|
||||
return driveMemberRemoveSpec{}, 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",
|
||||
).WithParam("--perm-type")
|
||||
}
|
||||
permType = ""
|
||||
} else if resourceType == "wiki" && permType == "" {
|
||||
permType = driveMemberAddDefaultPermType(resourceType)
|
||||
} else if resourceType != "wiki" && runtime.Changed("perm-type") {
|
||||
return driveMemberRemoveSpec{}, 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 := driveMemberRemoveSpec{
|
||||
Token: token,
|
||||
ResourceType: resourceType,
|
||||
MemberID: memberID,
|
||||
MemberType: memberType,
|
||||
MemberKind: memberKind,
|
||||
PermType: permType,
|
||||
}
|
||||
if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
|
||||
return driveMemberRemoveSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--member-type=opendepartmentid requires --as user; bot identity does not support removing department collaborators",
|
||||
).WithParam("--member-type")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func resolveDriveMemberRemoveMemberType(memberID, explicit string) (string, error) {
|
||||
memberType, err := normalizeDriveMemberAddEnumValue(explicit, driveMemberRemoveIDTypes, "--member-type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if memberType == "" {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--member-type is required; accepted values: %s",
|
||||
strings.Join(driveMemberRemoveIDTypes, ", "),
|
||||
).WithParam("--member-type")
|
||||
}
|
||||
|
||||
// User IDs are tenant-defined and may resemble another supported ID format.
|
||||
if memberType != "userid" {
|
||||
if expected := inferMemberTypeFromID(memberID); expected != "" && expected != memberType {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"member-id %q prefix implies --member-type %s, but --member-type %s was provided; fix the ID or use the matching member type",
|
||||
memberID,
|
||||
expected,
|
||||
memberType,
|
||||
).WithParam("--member-id")
|
||||
}
|
||||
}
|
||||
return memberType, nil
|
||||
}
|
||||
|
||||
func buildDriveMemberRemoveDryRun(spec driveMemberRemoveSpec) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Remove Drive collaborator/member permission").
|
||||
DELETE(driveMemberRemovePath(spec)).
|
||||
Params(spec.APIQueryParams()).
|
||||
Body(spec.Body())
|
||||
}
|
||||
|
||||
func driveMemberRemovePath(spec driveMemberRemoveSpec) string {
|
||||
return fmt.Sprintf(
|
||||
"/open-apis/drive/v1/permissions/%s/members/%s",
|
||||
validate.EncodePathSegment(spec.Token),
|
||||
validate.EncodePathSegment(spec.MemberID),
|
||||
)
|
||||
}
|
||||
|
||||
func executeDriveMemberRemove(runtime *common.RuntimeContext, spec driveMemberRemoveSpec) error {
|
||||
fmt.Fprintf(
|
||||
runtime.IO().ErrOut,
|
||||
"Removing Drive member %s (type=%s) from %s %s...\n",
|
||||
common.MaskToken(spec.MemberID),
|
||||
spec.MemberType,
|
||||
spec.ResourceType,
|
||||
common.MaskToken(spec.Token),
|
||||
)
|
||||
|
||||
if _, err := runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
driveMemberRemovePath(spec),
|
||||
spec.APIQueryParams(),
|
||||
spec.Body(),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := driveMemberRemoveOutput(spec)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Removed Drive member %s\n", common.MaskToken(spec.MemberID))
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func driveMemberRemoveOutput(spec driveMemberRemoveSpec) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"removed": true,
|
||||
"resource_token": spec.Token,
|
||||
"resource_type": spec.ResourceType,
|
||||
"member_id": spec.MemberID,
|
||||
"member_type": spec.MemberType,
|
||||
}
|
||||
if memberKind := driveMemberAddBodyType(spec.MemberType, spec.MemberKind); memberKind != "" {
|
||||
out["member_kind"] = memberKind
|
||||
}
|
||||
if spec.PermType != "" {
|
||||
out["perm_type"] = spec.PermType
|
||||
}
|
||||
return out
|
||||
}
|
||||
404
shortcuts/drive/drive_member_remove_test.go
Normal file
404
shortcuts/drive/drive_member_remove_test.go
Normal file
@@ -0,0 +1,404 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestDriveMemberRemoveMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if DriveMemberRemove.Service != "drive" || DriveMemberRemove.Command != "+member-remove" {
|
||||
t.Fatalf("shortcut identity = %s %s", DriveMemberRemove.Service, DriveMemberRemove.Command)
|
||||
}
|
||||
if DriveMemberRemove.Risk != "high-risk-write" {
|
||||
t.Fatalf("risk = %q, want high-risk-write", DriveMemberRemove.Risk)
|
||||
}
|
||||
if !reflect.DeepEqual(DriveMemberRemove.Scopes, []string{"docs:permission.member:delete"}) {
|
||||
t.Fatalf("scopes = %#v", DriveMemberRemove.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DriveMemberRemove.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("auth types = %#v", DriveMemberRemove.AuthTypes)
|
||||
}
|
||||
wantIDTypes := []string{
|
||||
"email", "openid", "openchat", "opendepartmentid",
|
||||
"userid", "unionid", "groupid", "wikispaceid",
|
||||
}
|
||||
if !reflect.DeepEqual(driveMemberRemoveIDTypes, wantIDTypes) {
|
||||
t.Fatalf("member types = %#v, want %#v", driveMemberRemoveIDTypes, wantIDTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberRemoveSpecRequestShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := driveMemberRemoveSpec{
|
||||
Token: "wikcnTok",
|
||||
ResourceType: "wiki",
|
||||
MemberID: "ou_member",
|
||||
MemberType: "openid",
|
||||
PermType: "single_page",
|
||||
}
|
||||
wantParams := map[string]interface{}{"type": "wiki", "member_type": "openid"}
|
||||
if got := spec.APIQueryParams(); !reflect.DeepEqual(got, wantParams) {
|
||||
t.Fatalf("params = %#v, want %#v", got, wantParams)
|
||||
}
|
||||
wantBody := map[string]interface{}{"type": "user", "perm_type": "single_page"}
|
||||
if got := spec.Body(); !reflect.DeepEqual(got, wantBody) {
|
||||
t.Fatalf("body = %#v, want %#v", got, wantBody)
|
||||
}
|
||||
|
||||
wikiSpaceSpec := driveMemberRemoveSpec{
|
||||
Token: "wikcnTok",
|
||||
ResourceType: "wiki",
|
||||
MemberID: "space_member",
|
||||
MemberType: "wikispaceid",
|
||||
MemberKind: "wiki_space_viewer",
|
||||
}
|
||||
if got := wikiSpaceSpec.Body(); !reflect.DeepEqual(got, map[string]interface{}{"type": "wiki_space_viewer"}) {
|
||||
t.Fatalf("wiki-space body = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberRemoveOutputConditionalFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wikiSpace := driveMemberRemoveOutput(driveMemberRemoveSpec{
|
||||
Token: "wikTok",
|
||||
ResourceType: "wiki",
|
||||
MemberID: "space_member",
|
||||
MemberType: "wikispaceid",
|
||||
MemberKind: "wiki_space_editor",
|
||||
})
|
||||
if wikiSpace["removed"] != true || wikiSpace["member_kind"] != "wiki_space_editor" {
|
||||
t.Fatalf("wiki-space output = %#v", wikiSpace)
|
||||
}
|
||||
if _, ok := wikiSpace["perm_type"]; ok {
|
||||
t.Fatalf("wiki-space output must omit perm_type: %#v", wikiSpace)
|
||||
}
|
||||
|
||||
userID := driveMemberRemoveOutput(driveMemberRemoveSpec{
|
||||
Token: "doxTok",
|
||||
ResourceType: "docx",
|
||||
MemberID: "custom_user",
|
||||
MemberType: "userid",
|
||||
})
|
||||
if userID["member_kind"] != "user" {
|
||||
t.Fatalf("userid output = %#v, want member_kind user", userID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberRemoveDryRunInfersURLAndDefaultsWikiPermType(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveMemberRemove, []string{
|
||||
"+member-remove",
|
||||
"--token", "https://example.feishu.cn/wiki/wikcnTok?from=share",
|
||||
"--member-id", "ou_member",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Data 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"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.Data.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1", len(got.Data.API))
|
||||
}
|
||||
call := got.Data.API[0]
|
||||
if call.Method != "DELETE" || call.URL != "/open-apis/drive/v1/permissions/wikcnTok/members/ou_member" {
|
||||
t.Fatalf("call = %#v", call)
|
||||
}
|
||||
if call.Params["type"] != "wiki" || call.Params["member_type"] != "openid" {
|
||||
t.Fatalf("params = %#v", call.Params)
|
||||
}
|
||||
if call.Body["type"] != "user" || call.Body["perm_type"] != "container" {
|
||||
t.Fatalf("body = %#v", call.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberRemoveValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
defaultAs string
|
||||
wantParam string
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "rejects slash in normalized token",
|
||||
args: []string{"--token", "token/with/slash", "--type", "docx", "--member-id", "ou_user", "--member-type", "openid"},
|
||||
wantParam: "--token",
|
||||
wantText: "cannot contain '/'",
|
||||
},
|
||||
{
|
||||
name: "rejects slash in member ID",
|
||||
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "userid/with/slash", "--member-type", "userid"},
|
||||
wantParam: "--member-id",
|
||||
wantText: "cannot contain '/'",
|
||||
},
|
||||
{
|
||||
name: "rejects multiple members",
|
||||
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "ou_a,ou_b", "--member-type", "openid"},
|
||||
wantParam: "--member-id",
|
||||
wantText: "exactly one collaborator ID",
|
||||
},
|
||||
{
|
||||
name: "rejects member prefix mismatch",
|
||||
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "oc_chat", "--member-type", "openid"},
|
||||
wantParam: "--member-id",
|
||||
wantText: "implies --member-type openchat",
|
||||
},
|
||||
{
|
||||
name: "rejects app ID",
|
||||
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "cli_app", "--member-type", "appid"},
|
||||
wantParam: "--member-type",
|
||||
wantText: `invalid value "appid"`,
|
||||
},
|
||||
{
|
||||
name: "rejects wiki-space ID outside wiki",
|
||||
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "space_x", "--member-type", "wikispaceid", "--member-kind", "wiki_space_member"},
|
||||
wantParam: "--member-type",
|
||||
wantText: "only applies when resource type is wiki",
|
||||
},
|
||||
{
|
||||
name: "requires member kind for wiki space ID",
|
||||
args: []string{"--token", "wikTok", "--type", "wiki", "--member-id", "space_x", "--member-type", "wikispaceid"},
|
||||
wantParam: "--member-kind",
|
||||
wantText: "--member-kind is required",
|
||||
},
|
||||
{
|
||||
name: "rejects member kind for ordinary member",
|
||||
args: []string{"--token", "wikTok", "--type", "wiki", "--member-id", "ou_x", "--member-type", "openid", "--member-kind", "wiki_space_member"},
|
||||
wantParam: "--member-kind",
|
||||
wantText: "only applies",
|
||||
},
|
||||
{
|
||||
name: "rejects perm type outside wiki",
|
||||
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "ou_x", "--member-type", "openid", "--perm-type", "single_page"},
|
||||
wantParam: "--perm-type",
|
||||
wantText: "only applies when resource type is wiki",
|
||||
},
|
||||
{
|
||||
name: "rejects perm type for wiki space member",
|
||||
args: []string{"--token", "wikTok", "--type", "wiki", "--member-id", "space_x", "--member-type", "wikispaceid", "--member-kind", "wiki_space_member", "--perm-type", "container"},
|
||||
wantParam: "--perm-type",
|
||||
wantText: "not supported when --member-type=wikispaceid",
|
||||
},
|
||||
{
|
||||
name: "rejects department with bot",
|
||||
args: []string{"--token", "doxTok", "--type", "docx", "--member-id", "od_dept", "--member-type", "opendepartmentid"},
|
||||
defaultAs: "bot",
|
||||
wantParam: "--member-type",
|
||||
wantText: "requires --as user",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
args := append([]string{"+member-remove"}, tt.args...)
|
||||
args = append(args, "--dry-run", "--as", tt.defaultAs)
|
||||
if tt.defaultAs == "" {
|
||||
args[len(args)-1] = "user"
|
||||
}
|
||||
err := mountAndRunDrive(t, DriveMemberRemove, args, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("error = %v, want text %q", err, tt.wantText)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed validation error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("problem = %#v", problem)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("validation error = %#v, want param %q", validationErr, tt.wantParam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberRemoveAcceptsUserID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveMemberRemove, []string{
|
||||
"+member-remove",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--member-id", "ou_tenant_defined_user",
|
||||
"--member-type", "userid",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.Data.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1", len(got.Data.API))
|
||||
}
|
||||
if got.Data.API[0].Params["member_type"] != "userid" {
|
||||
t.Fatalf("params = %#v", got.Data.API[0].Params)
|
||||
}
|
||||
if got.Data.API[0].Body["type"] != "user" {
|
||||
t.Fatalf("body = %#v", got.Data.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberRemoveExecuteSuccess(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: "DELETE",
|
||||
URL: "/open-apis/drive/v1/permissions/wikcnSecretToken/members/ou_secret_member",
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberRemove, []string{
|
||||
"+member-remove",
|
||||
"--token", "wikcnSecretToken",
|
||||
"--type", "wiki",
|
||||
"--member-id", "ou_secret_member",
|
||||
"--member-type", "openid",
|
||||
"--perm-type", "single_page",
|
||||
"--as", "user",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(capturedQuery, "type=wiki") || !strings.Contains(capturedQuery, "member_type=openid") {
|
||||
t.Fatalf("query = %q", capturedQuery)
|
||||
}
|
||||
var capturedBody map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &capturedBody); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
wantBody := map[string]interface{}{"type": "user", "perm_type": "single_page"}
|
||||
if !reflect.DeepEqual(capturedBody, wantBody) {
|
||||
t.Fatalf("body = %#v, want %#v", capturedBody, wantBody)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["removed"] != true || data["resource_token"] != "wikcnSecretToken" || data["resource_type"] != "wiki" ||
|
||||
data["member_id"] != "ou_secret_member" || data["member_type"] != "openid" ||
|
||||
data["member_kind"] != "user" || data["perm_type"] != "single_page" {
|
||||
t.Fatalf("output = %#v", data)
|
||||
}
|
||||
if strings.Contains(stderr.String(), "wikcnSecretToken") || strings.Contains(stderr.String(), "ou_secret_member") {
|
||||
t.Fatalf("stderr exposes unmasked identifiers: %q", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), common.MaskToken("wikcnSecretToken")) || !strings.Contains(stderr.String(), common.MaskToken("ou_secret_member")) {
|
||||
t.Fatalf("stderr missing masked identifiers: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberRemoveTypedPermissionErrorPassesThrough(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/permissions/doxTok/members/ou_member",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1063002,
|
||||
"msg": "Permission denied",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberRemove, []string{
|
||||
"+member-remove",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--member-id", "ou_member",
|
||||
"--member-type", "openid",
|
||||
"--as", "user",
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error")
|
||||
}
|
||||
var permissionErr *errs.PermissionError
|
||||
if !errors.As(err, &permissionErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if permissionErr.Code != 1063002 {
|
||||
t.Fatalf("code = %d, want 1063002", permissionErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberRemoveRequiresConfirmation(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveMemberRemove, []string{
|
||||
"+member-remove",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--member-id", "ou_member",
|
||||
"--member-type", "openid",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("expected confirmation error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DriveMemberRemove,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+member-remove",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -25,6 +25,7 @@ metadata:
|
||||
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
|
||||
- 高风险写操作(删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户明确要**移除单个云文档协作者权限**时,使用 `lark-cli drive +member-remove`;先阅读 [`references/lark-drive-member-remove.md`](references/lark-drive-member-remove.md)。这是高风险写操作,真实执行必须确认准确的资源、成员 ID/type 和 wiki 权限范围,并显式传 `--yes`。
|
||||
- 用户要为指定飞书文档**设置 / 修改密级标签(secure label)**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
@@ -160,6 +161,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+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`。 |
|
||||
| [`+member-remove`](references/lark-drive-member-remove.md) | 移除一个 Drive 文档、文件、文件夹或 wiki 节点协作者;封装 Drive permission member delete,真实写入需要 `--yes`。 |
|
||||
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
|
||||
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |
|
||||
|
||||
|
||||
59
skills/lark-drive/references/lark-drive-member-remove.md
Normal file
59
skills/lark-drive/references/lark-drive-member-remove.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# drive +member-remove(移除协作者权限)
|
||||
|
||||
> 这是高风险写操作。真实执行会移除权限,需要核对资源和成员后显式加 `--yes`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli drive +member-remove \
|
||||
--token "<bare_token_or_url>" \
|
||||
--type docx \
|
||||
--member-id "ou_xxx" \
|
||||
--member-type openid \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 裸 token 或完整 URL。URL 可从路径推断类型;裸 token 必须同时传 `--type`。 |
|
||||
| `--type` | 条件必填 | 资源类型:`docx` / `doc` / `sheet` / `bitable` / `file` / `folder` / `wiki` / `mindnote` / `slides` / `minutes`。完整 URL 可省略。 |
|
||||
| `--member-id` | 是 | 要移除的单个协作者 ID。逗号分隔的多成员输入会被拒绝;批量场景应逐个调用。 |
|
||||
| `--member-type` | 是 | ID 类型:`email` / `openid` / `openchat` / `opendepartmentid` / `userid` / `unionid` / `groupid` / `wikispaceid`。 |
|
||||
| `--member-kind` | 条件必填 | 仅 `--member-type=wikispaceid` 使用:未启用知识库成员分组时传 `wiki_space_member`,启用后根据权限传 `wiki_space_viewer` 或 `wiki_space_editor`。 |
|
||||
| `--perm-type` | 否 | 仅 wiki 协作者使用:`container`(默认,当前页面及子页面)或 `single_page`(仅当前页面)。 |
|
||||
| `--dry-run` | 否 | 只预览 DELETE URL、query 和 body,不调用接口。 |
|
||||
| `--yes` | 真实执行时是 | 确认高风险权限移除操作。 |
|
||||
|
||||
## 输出
|
||||
|
||||
以移除 `openid` 类型的用户协作者为例,成功后返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"removed": true,
|
||||
"resource_token": "doxcnxxx",
|
||||
"resource_type": "docx",
|
||||
"member_id": "ou_xxx",
|
||||
"member_type": "openid",
|
||||
"member_kind": "user"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Wiki 普通协作者还会返回 `perm_type`;`wikispaceid` 返回所传的 `member_kind`。
|
||||
|
||||
`removed: true` 表示删除请求成功完成,不保证该权限此前一定存在。
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **身份支持**:支持 `--as user` 和 `--as bot`。
|
||||
- **部门协作者**:`--member-type=opendepartmentid` 只能配合 `--as user`;bot 身份会在客户端提前拒绝。
|
||||
- **安全编码**:资源 token 和 member ID 都作为独立 path segment 编码。
|
||||
- **Wiki 范围**:普通 wiki 协作者默认删除 `container` 权限;只删除当前页面权限时显式传 `single_page`。
|
||||
- **Wiki 空间成员**:`--member-type=wikispaceid` 仅支持 `--type=wiki`;必须用 `--member-kind` 指明成员角色,并且不能同时传 `--perm-type`。
|
||||
- **错误处理**:OpenAPI 返回的 typed error 原样透传,可根据错误信封中的 subtype、code、hint 和权限信息处理。
|
||||
236
tests/cli_e2e/drive/drive_member_remove_dryrun_test.go
Normal file
236
tests/cli_e2e/drive/drive_member_remove_dryrun_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestDrive_MemberRemoveDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantResourceType string
|
||||
wantMemberType string
|
||||
wantMemberKind string
|
||||
wantPermType string
|
||||
}{
|
||||
{
|
||||
name: "docx URL infers resource type",
|
||||
args: []string{
|
||||
"drive", "+member-remove",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnRemove001?from=share",
|
||||
"--member-id", "ou_remove_user",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnRemove001/members/ou_remove_user",
|
||||
wantResourceType: "docx",
|
||||
wantMemberType: "openid",
|
||||
wantMemberKind: "user",
|
||||
},
|
||||
{
|
||||
name: "user ID is accepted",
|
||||
args: []string{
|
||||
"drive", "+member-remove",
|
||||
"--token", "doxcnRemoveUserID",
|
||||
"--type", "docx",
|
||||
"--member-id", "tenant_defined_user",
|
||||
"--member-type", "userid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnRemoveUserID/members/tenant_defined_user",
|
||||
wantResourceType: "docx",
|
||||
wantMemberType: "userid",
|
||||
wantMemberKind: "user",
|
||||
},
|
||||
{
|
||||
name: "ordinary wiki member defaults container scope",
|
||||
args: []string{
|
||||
"drive", "+member-remove",
|
||||
"--token", "wikcnE2E002",
|
||||
"--type", "wiki",
|
||||
"--member-id", "oc_remove_chat",
|
||||
"--member-type", "openchat",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/wikcnE2E002/members/oc_remove_chat",
|
||||
wantResourceType: "wiki",
|
||||
wantMemberType: "openchat",
|
||||
wantMemberKind: "chat",
|
||||
wantPermType: "container",
|
||||
},
|
||||
{
|
||||
name: "wiki single-page scope",
|
||||
args: []string{
|
||||
"drive", "+member-remove",
|
||||
"--token", "wikcnE2E003",
|
||||
"--type", "wiki",
|
||||
"--member-id", "ou_remove_user",
|
||||
"--member-type", "openid",
|
||||
"--perm-type", "single_page",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/wikcnE2E003/members/ou_remove_user",
|
||||
wantResourceType: "wiki",
|
||||
wantMemberType: "openid",
|
||||
wantMemberKind: "user",
|
||||
wantPermType: "single_page",
|
||||
},
|
||||
{
|
||||
name: "wiki-space member uses explicit kind without perm type",
|
||||
args: []string{
|
||||
"drive", "+member-remove",
|
||||
"--token", "wikcnE2E004",
|
||||
"--type", "wiki",
|
||||
"--member-id", "space_remove_member",
|
||||
"--member-type", "wikispaceid",
|
||||
"--member-kind", "wiki_space_editor",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/wikcnE2E004/members/space_remove_member",
|
||||
wantResourceType: "wiki",
|
||||
wantMemberType: "wikispaceid",
|
||||
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
|
||||
require.Equal(t, "DELETE", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, tt.wantURL, clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, tt.wantResourceType, clie2e.DryRunGet(out, "api.0.params.type").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, tt.wantMemberType, clie2e.DryRunGet(out, "api.0.params.member_type").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, tt.wantMemberKind, clie2e.DryRunGet(out, "api.0.body.type").String(), "stdout:\n%s", out)
|
||||
|
||||
permType := clie2e.DryRunGet(out, "api.0.body.perm_type")
|
||||
if tt.wantPermType == "" {
|
||||
require.False(t, permType.Exists(), "perm_type should be omitted\nstdout:\n%s", out)
|
||||
} else {
|
||||
require.Equal(t, tt.wantPermType, permType.String(), "stdout:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_MemberRemoveDryRunRejectsInvalidInputs(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
defaultAs string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "slash in normalized token is rejected",
|
||||
args: []string{"drive", "+member-remove", "--token", "token/with/slash", "--type", "docx", "--member-id", "ou_user", "--member-type", "openid", "--dry-run"},
|
||||
wantErr: "--token must resolve to a single resource token and cannot contain '/'",
|
||||
},
|
||||
{
|
||||
name: "slash in member ID is rejected",
|
||||
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--type", "docx", "--member-id", "userid/with/slash", "--member-type", "userid", "--dry-run"},
|
||||
wantErr: "--member-id must be a single collaborator ID and cannot contain '/'",
|
||||
},
|
||||
{
|
||||
name: "bare token requires type",
|
||||
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--member-id", "ou_user", "--member-type", "openid", "--dry-run"},
|
||||
wantErr: "--type is required when --token is a bare token",
|
||||
},
|
||||
{
|
||||
name: "multiple members are rejected",
|
||||
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--type", "docx", "--member-id", "ou_a,ou_b", "--member-type", "openid", "--dry-run"},
|
||||
wantErr: "exactly one collaborator ID",
|
||||
},
|
||||
{
|
||||
name: "wiki-space ID requires member kind",
|
||||
args: []string{"drive", "+member-remove", "--token", "wikcnRemove", "--type", "wiki", "--member-id", "space_member", "--member-type", "wikispaceid", "--dry-run"},
|
||||
wantErr: "--member-kind is required",
|
||||
},
|
||||
{
|
||||
name: "app ID is rejected",
|
||||
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--type", "docx", "--member-id", "cli_app", "--member-type", "appid", "--dry-run"},
|
||||
wantErr: "allowed: email, openid, openchat, opendepartmentid, userid, unionid, groupid, wikispaceid",
|
||||
},
|
||||
{
|
||||
name: "wiki-space ID is rejected outside wiki",
|
||||
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--type", "docx", "--member-id", "space_member", "--member-type", "wikispaceid", "--member-kind", "wiki_space_member", "--dry-run"},
|
||||
wantErr: "only applies when resource type is wiki",
|
||||
},
|
||||
{
|
||||
name: "non-wiki rejects perm type",
|
||||
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--type", "docx", "--member-id", "ou_user", "--member-type", "openid", "--perm-type", "single_page", "--dry-run"},
|
||||
wantErr: "only applies when resource type is wiki",
|
||||
},
|
||||
{
|
||||
name: "bot rejects department member",
|
||||
args: []string{"drive", "+member-remove", "--token", "doxcnRemove", "--type", "docx", "--member-id", "od_department", "--member-type", "opendepartmentid", "--dry-run"},
|
||||
defaultAs: "bot",
|
||||
wantErr: "requires --as user",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
defaultAs := tt.defaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = "user"
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: defaultAs})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Contains(t, result.Stdout+result.Stderr, tt.wantErr, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_MemberRemoveRequiresConfirmation(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+member-remove",
|
||||
"--token", "doxcnRemove",
|
||||
"--type", "docx",
|
||||
"--member-id", "ou_user",
|
||||
"--member-type", "openid",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 10)
|
||||
require.Contains(t, result.Stderr, "confirmation_required", "stderr:\n%s", result.Stderr)
|
||||
}
|
||||
108
tests/cli_e2e/drive/drive_member_remove_workflow_test.go
Normal file
108
tests/cli_e2e/drive/drive_member_remove_workflow_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
func TestDrive_MemberRemoveWorkflowAsUser(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderToken := CreateDriveFolder(t, t, ctx, "lark-cli-e2e-member-remove-"+suffix, "user", "")
|
||||
docToken := createMemberRemoveWorkflowDoc(t, ctx, folderToken, suffix)
|
||||
botOpenID := getMemberRemoveWorkflowBotOpenID(t, ctx)
|
||||
|
||||
addResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", docToken,
|
||||
"--type", "docx",
|
||||
"--member-id", botOpenID,
|
||||
"--member-type", "openid",
|
||||
"--perm", "view",
|
||||
"--yes",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
addResult.AssertExitCode(t, 0)
|
||||
addResult.AssertStdoutStatus(t, true)
|
||||
require.Equal(t, botOpenID, gjson.Get(addResult.Stdout, "data.member_id").String(), "stdout:\n%s", addResult.Stdout)
|
||||
|
||||
removeResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+member-remove",
|
||||
"--token", docToken,
|
||||
"--type", "docx",
|
||||
"--member-id", botOpenID,
|
||||
"--member-type", "openid",
|
||||
"--yes",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
removeResult.AssertExitCode(t, 0)
|
||||
removeResult.AssertStdoutStatus(t, true)
|
||||
require.True(t, gjson.Get(removeResult.Stdout, "data.removed").Bool(), "stdout:\n%s", removeResult.Stdout)
|
||||
require.Equal(t, docToken, gjson.Get(removeResult.Stdout, "data.resource_token").String(), "stdout:\n%s", removeResult.Stdout)
|
||||
require.Equal(t, "docx", gjson.Get(removeResult.Stdout, "data.resource_type").String(), "stdout:\n%s", removeResult.Stdout)
|
||||
require.Equal(t, botOpenID, gjson.Get(removeResult.Stdout, "data.member_id").String(), "stdout:\n%s", removeResult.Stdout)
|
||||
require.Equal(t, "openid", gjson.Get(removeResult.Stdout, "data.member_type").String(), "stdout:\n%s", removeResult.Stdout)
|
||||
}
|
||||
|
||||
func createMemberRemoveWorkflowDoc(t *testing.T, ctx context.Context, folderToken, suffix string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+create",
|
||||
"--parent-token", folderToken,
|
||||
"--doc-format", "markdown",
|
||||
"--content", "# member-remove-" + suffix + "\n\nTemporary permission workflow fixture.",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
docToken := gjson.Get(result.Stdout, "data.document.document_id").String()
|
||||
require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout)
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
|
||||
deleteResult, deleteErr := DeleteDriveResourceAndVerify(cleanupCtx, docToken, "docx", "user")
|
||||
clie2e.ReportCleanupFailure(t, "delete member-remove workflow doc "+docToken, deleteResult, deleteErr)
|
||||
})
|
||||
return docToken
|
||||
}
|
||||
|
||||
func getMemberRemoveWorkflowBotOpenID(t *testing.T, ctx context.Context) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"auth", "status", "--json", "--verify"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.True(t, gjson.Get(result.Stdout, "identities.bot.verified").Bool(), "stdout:\n%s", result.Stdout)
|
||||
|
||||
openID := gjson.Get(result.Stdout, "identities.bot.openId").String()
|
||||
require.NotEmpty(t, openID, "stdout:\n%s", result.Stdout)
|
||||
return openID
|
||||
}
|
||||
Reference in New Issue
Block a user