From a51835df113803851a3cc618dde0b38b65866939 Mon Sep 17 00:00:00 2001 From: yxy-bd Date: Wed, 5 Aug 2026 12:20:47 +0800 Subject: [PATCH 1/6] feat: add miaoda app collaborator management --- errs/subtypes.go | 21 +- shortcuts/apps/apps_member.go | 283 ++++++++ shortcuts/apps/apps_member_common.go | 684 ++++++++++++++++++ shortcuts/apps/apps_member_common_test.go | 451 ++++++++++++ shortcuts/apps/apps_member_response_test.go | 395 ++++++++++ shortcuts/apps/shortcuts.go | 6 + shortcuts/apps/shortcuts_test.go | 47 +- skills/lark-apps/SKILL.md | 29 +- tests/cli_e2e/apps/coverage.md | 19 +- .../cli_e2e/dryrun/apps_member_dryrun_test.go | 213 ++++++ 10 files changed, 2127 insertions(+), 21 deletions(-) create mode 100644 shortcuts/apps/apps_member.go create mode 100644 shortcuts/apps/apps_member_common.go create mode 100644 shortcuts/apps/apps_member_common_test.go create mode 100644 shortcuts/apps/apps_member_response_test.go create mode 100644 tests/cli_e2e/dryrun/apps_member_dryrun_test.go diff --git a/errs/subtypes.go b/errs/subtypes.go index a3968ac2d2..9d908f0e1d 100644 --- a/errs/subtypes.go +++ b/errs/subtypes.go @@ -58,16 +58,17 @@ const ( // CategoryAPI subtypes const ( - SubtypeRateLimit Subtype = "rate_limit" // request rate limit exceeded - SubtypeConflict Subtype = "conflict" // resource state conflict (e.g. concurrent modification) - SubtypeCrossTenant Subtype = "cross_tenant" // operation crosses tenant boundary (not supported) - SubtypeCrossBrand Subtype = "cross_brand" // operation crosses brand boundary (feishu vs lark, not supported) - SubtypeInvalidParameters Subtype = "invalid_parameters" // API-side parameter validation rejected the request - SubtypeOwnershipMismatch Subtype = "ownership_mismatch" // caller is not the resource owner - SubtypeNotFound Subtype = "not_found" // referenced resource does not exist (HTTP 404 alignment) - SubtypeServerError Subtype = "server_error" // upstream server-side transient error (HTTP 5xx alignment, retryable) - SubtypeQuotaExceeded Subtype = "quota_exceeded" // resource quota / collection size limit reached (assignees, followers, members, etc.) - SubtypeAlreadyExists Subtype = "already_exists" // idempotency violation: resource already exists in target state + SubtypeRateLimit Subtype = "rate_limit" // request rate limit exceeded + SubtypeConflict Subtype = "conflict" // resource state conflict (e.g. concurrent modification) + SubtypeCrossTenant Subtype = "cross_tenant" // operation crosses tenant boundary (not supported) + SubtypeCrossBrand Subtype = "cross_brand" // operation crosses brand boundary (feishu vs lark, not supported) + SubtypeInvalidParameters Subtype = "invalid_parameters" // API-side parameter validation rejected the request + SubtypeOwnershipMismatch Subtype = "ownership_mismatch" // caller is not the resource owner + SubtypeNotFound Subtype = "not_found" // referenced resource does not exist (HTTP 404 alignment) + SubtypeServerError Subtype = "server_error" // upstream server-side transient error (HTTP 5xx alignment, retryable) + SubtypeQuotaExceeded Subtype = "quota_exceeded" // resource quota / collection size limit reached (assignees, followers, members, etc.) + SubtypeAlreadyExists Subtype = "already_exists" // idempotency violation: resource already exists in target state + SubtypeFeatureNotAvailable Subtype = "feature_not_available" // the resource exists, but this integration cannot manage the requested feature ) // CategoryPolicy subtypes (security-policy envelope shape) diff --git a/shortcuts/apps/apps_member.go b/shortcuts/apps/apps_member.go new file mode 100644 index 0000000000..c3ac664e77 --- /dev/null +++ b/shortcuts/apps/apps_member.go @@ -0,0 +1,283 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "text/tabwriter" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + memberReadHint = "verify --app-id identifies a Miaoda app you can access; list apps with `lark-cli apps +list`" + memberWriteHint = "verify the app, external member ID, and current collaborator policy; read the latest state before retrying" +) + +var AppsMemberList = common.Shortcut{ + Service: appsService, Command: "+member-list", Description: "List application collaborators", + Risk: "read", Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, + Tips: []string{"Example: lark-cli apps +member-list --app-id "}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app ID (app_...)"}, + {Name: "role", Desc: "filter permission", Enum: memberRoles}, + {Name: "member-type", Desc: "filter collaborator type", Enum: memberListTypes}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"}, + {Name: "page-token", Desc: "opaque page token returned by the previous page"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := validateMemberAppID(rctx); err != nil { + return err + } + _, err := buildMemberListParams(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + params, _ := buildMemberListParams(rctx) + return common.NewDryRunAPI(). + GET(memberListURL(rctx)). + Desc("List application collaborators"). + Params(params) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + params, err := buildMemberListParams(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("GET", memberListURL(rctx), params, nil) + if err != nil { + return withAppsHint(normalizeMemberAPIError(err), memberReadHint) + } + out, err := projectMemberListData(data) + if err != nil { + return err + } + rctx.OutFormat(out, nil, func(w io.Writer) { renderMemberListPretty(w, out) }) + return nil + }, +} + +var AppsMemberAdd = common.Shortcut{ + Service: appsService, Command: "+member-add", Description: "Add an application collaborator", + Risk: "high-risk-write", Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, + Tips: []string{"Example: lark-cli apps +member-add --app-id --member-type openid --member-id --perm view --yes"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app ID (app_...)"}, + {Name: "member-type", Desc: "collaborator ID type", Required: true, Enum: memberWriteTypes}, + {Name: "member-id", Desc: "one external user, chat, or department ID", Required: true}, + {Name: "perm", Desc: "collaborator permission", Required: true, Enum: memberRoles}, + {Name: "need-notification", Type: "bool", Desc: "notify the collaborator"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + return validateMemberMutation(rctx, true) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildMemberAddRequest(rctx) + return common.NewDryRunAPI(). + POST(memberListURL(rctx)). + Desc("Add an application collaborator"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + body, err := buildMemberAddRequest(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", memberListURL(rctx), nil, body) + if err != nil { + return withAppsHint(normalizeMemberAPIError(err), memberWriteHint) + } + out, err := projectMemberAddData(data) + if err != nil { + return err + } + rctx.OutFormat(out, nil, func(w io.Writer) { renderMemberMutationPretty(w, "added", out.Member, out.Changed) }) + return nil + }, +} + +var AppsMemberUpdate = common.Shortcut{ + Service: appsService, Command: "+member-update", Description: "Update an application collaborator", + Risk: "high-risk-write", Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, + Tips: []string{"Example: lark-cli apps +member-update --app-id --member-type openid --member-id --perm edit --yes"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app ID (app_...)"}, + {Name: "member-type", Desc: "collaborator ID type", Required: true, Enum: memberWriteTypes}, + {Name: "member-id", Desc: "one external user, chat, or department ID", Required: true}, + {Name: "perm", Desc: "collaborator permission", Required: true, Enum: memberRoles}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + return validateMemberMutation(rctx, true) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildMemberUpdateRequest(rctx) + return common.NewDryRunAPI(). + PATCH(memberListURL(rctx)). + Desc("Update an application collaborator"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + body, err := buildMemberUpdateRequest(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("PATCH", memberListURL(rctx), nil, body) + if err != nil { + return withAppsHint(normalizeMemberAPIError(err), memberWriteHint) + } + out, err := projectMemberUpdateData(data) + if err != nil { + return err + } + rctx.OutFormat(out, nil, func(w io.Writer) { + renderMemberMutationPretty(w, "updated", out.Member, out.Changed) + fmt.Fprintf(w, "permission: %s -> %s\n", memberDisplayValue(out.BeforeRole), memberDisplayValue(out.AfterRole)) + }) + return nil + }, +} + +var AppsMemberRemove = common.Shortcut{ + Service: appsService, Command: "+member-remove", Description: "Remove an application collaborator", + Risk: "high-risk-write", Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, + Tips: []string{"Example: lark-cli apps +member-remove --app-id --member-type openid --member-id --yes"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app ID (app_...)"}, + {Name: "member-type", Desc: "collaborator ID type", Required: true, Enum: memberWriteTypes}, + {Name: "member-id", Desc: "one external user, chat, or department ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + return validateMemberMutation(rctx, false) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildMemberRemoveRequest(rctx) + return common.NewDryRunAPI(). + POST(memberRemoveURL(rctx)). + Desc("Remove an application collaborator"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + body, err := buildMemberRemoveRequest(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", memberRemoveURL(rctx), nil, body) + if err != nil { + return withAppsHint(normalizeMemberAPIError(err), memberWriteHint) + } + out, err := projectMemberRemoveData(data) + if err != nil { + return err + } + rctx.OutFormat(out, nil, func(w io.Writer) { renderMemberMutationPretty(w, "removed", out.Member, out.Changed) }) + return nil + }, +} + +var AppsMemberSettingsGet = common.Shortcut{ + Service: appsService, Command: "+member-settings-get", Description: "Get application collaborator settings", + Risk: "read", Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, + Tips: []string{"Example: lark-cli apps +member-settings-get --app-id "}, + HasFormat: true, + Flags: []common.Flag{{Name: "app-id", Desc: "Miaoda app ID (app_...)"}}, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + return validateMemberAppID(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET(memberSettingsURL(rctx)). + Desc("Get application collaborator settings") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("GET", memberSettingsURL(rctx), nil, nil) + if err != nil { + return withAppsHint(normalizeMemberAPIError(err), memberReadHint) + } + out, err := projectMemberSettingsGetData(data) + if err != nil { + return err + } + rctx.OutFormat(out, nil, func(w io.Writer) { renderMemberSettingsPretty(w, out.Settings) }) + return nil + }, +} + +var AppsMemberSettingsSet = common.Shortcut{ + Service: appsService, Command: "+member-settings-set", Description: "Update application collaborator settings", + Risk: "high-risk-write", Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, + Tips: []string{"Example: lark-cli apps +member-settings-set --app-id --external-access enabled --yes"}, + HasFormat: true, + Flags: memberSettingsSetFlags(), + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + return validateMemberSettingsSet(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildMemberSettingsUpdateRequest(rctx) + return common.NewDryRunAPI(). + PATCH(memberSettingsURL(rctx)). + Desc("Update application collaborator settings"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + body, err := buildMemberSettingsUpdateRequest(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("PATCH", memberSettingsURL(rctx), nil, body) + if err != nil { + return withAppsHint(normalizeMemberAPIError(err), memberWriteHint) + } + out, err := projectMemberSettingsSetData(data) + if err != nil { + return err + } + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "changed: %t\n", out.Changed) + renderMemberSettingsPretty(w, out.Settings) + }) + return nil + }, +} + +func renderMemberListPretty(w io.Writer, out memberListOutput) { + fmt.Fprintf(w, "%d collaborator(s)\n", len(out.Items)) + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "MEMBER_TYPE\tMEMBER_ID\tNAME\tROLE") + for _, item := range out.Items { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + memberDisplayValue(item.MemberType), + memberDisplayValue(item.MemberID), + memberDisplayValue(item.Name), + memberDisplayValue(item.Role), + ) + } + _ = tw.Flush() + if out.HasMore { + fmt.Fprintf(w, "next page token: %s\n", memberDisplayValue(out.PageToken)) + } +} + +func renderMemberMutationPretty(w io.Writer, action string, member memberOutput, changed bool) { + fmt.Fprintf(w, "%s: %s %s (%s); changed: %t\n", + action, + memberDisplayValue(member.MemberType), + memberDisplayValue(member.MemberID), + memberDisplayValue(member.Role), + changed, + ) +} + +func renderMemberSettingsPretty(w io.Writer, settings memberSettingsResponse) { + for _, spec := range memberSettingSpecs { + if value := spec.responseValue(settings); value != nil { + fmt.Fprintf(w, "%s: %s\n", spec.field, memberDisplayValue(*value)) + } + } +} diff --git a/shortcuts/apps/apps_member_common.go b/shortcuts/apps/apps_member_common.go new file mode 100644 index 0000000000..53aaac8934 --- /dev/null +++ b/shortcuts/apps/apps_member_common.go @@ -0,0 +1,684 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "fmt" + "strings" + "unicode" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + memberListPath = apiBasePath + "/apps/%s/members" + memberRemovePath = apiBasePath + "/apps/%s/members/remove" + memberSettingsPath = apiBasePath + "/apps/%s/member-settings" + defaultMemberPageSize = 20 + maxMemberPageSize = 100 +) + +var ( + memberWriteTypes = []string{"openid", "openchat", "opendepartmentid"} + memberListTypes = []string{"user", "department", "chat"} + memberRoles = []string{"view", "edit", "full_access"} +) + +func normalizeMemberAPIError(err error) error { + if err == nil { + return nil + } + problem, ok := errs.ProblemOf(err) + if !ok { + return err + } + switch problem.Code { + case 40004, 3340004: + problem.Subtype = errs.SubtypeInvalidParameters + problem.Hint = "discard --page-token and restart from the first page" + case 40005, 3340005: + problem.Subtype = errs.SubtypeFeatureNotAvailable + problem.Message = "Collaborator management is not available for this app via lark-cli." + problem.Hint = "Open this app in Miaoda and manage collaborators from its permission settings." + problem.Retryable = false + case 40400, 3340400: + problem.Subtype = errs.SubtypeNotFound + } + return err +} + +type memberIdentityRequest struct { + UserOpenID string `json:"user_open_id,omitempty"` + DepartmentID string `json:"department_id,omitempty"` + ChatID string `json:"chat_id,omitempty"` +} + +type memberAddRequest struct { + memberIdentityRequest + Role string `json:"role"` + NeedNotification *bool `json:"need_notification,omitempty"` +} + +type memberUpdateRequest struct { + memberIdentityRequest + Role string `json:"role"` +} + +type memberRemoveRequest struct { + memberIdentityRequest +} + +type memberSettingsUpdateRequest struct { + ExternalAccess *string `json:"external_access,omitempty"` + ExternalInvite *string `json:"external_invite,omitempty"` + LinkShare *string `json:"link_share,omitempty"` + ManageCollaboratorsBy *string `json:"manage_collaborators_by,omitempty"` + CommentBy *string `json:"comment_by,omitempty"` + CopyDownloadBy *string `json:"copy_download_by,omitempty"` +} + +type memberAPIRecord struct { + MemberType string `json:"member_type"` + UserOpenID *string `json:"user_open_id,omitempty"` + DepartmentID *string `json:"department_id,omitempty"` + ChatID *string `json:"chat_id,omitempty"` + Name string `json:"name,omitempty"` + Role string `json:"role"` +} + +type memberOutput struct { + MemberType string `json:"member_type"` + MemberID string `json:"member_id"` + Name string `json:"name,omitempty"` + Role string `json:"role"` +} + +type memberSettingsResponse struct { + ExternalAccess *string `json:"external_access,omitempty"` + ExternalInvite *string `json:"external_invite,omitempty"` + LinkShare *string `json:"link_share,omitempty"` + ManageCollaboratorsBy *string `json:"manage_collaborators_by,omitempty"` + CommentBy *string `json:"comment_by,omitempty"` + CopyDownloadBy *string `json:"copy_download_by,omitempty"` +} + +type memberSettingSpec struct { + flag string + field string + description string + allowed []string + setRequest func(*memberSettingsUpdateRequest, *string) + responseValue func(memberSettingsResponse) *string +} + +var memberSettingSpecs = []memberSettingSpec{ + { + flag: "external-access", field: "external_access", description: "external sharing", + allowed: []string{"enabled", "disabled"}, + setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.ExternalAccess = value }, + responseValue: func(settings memberSettingsResponse) *string { return settings.ExternalAccess }, + }, + { + flag: "external-invite", field: "external_invite", description: "external collaborator invitations", + allowed: []string{"enabled", "disabled"}, + setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.ExternalInvite = value }, + responseValue: func(settings memberSettingsResponse) *string { return settings.ExternalInvite }, + }, + { + flag: "link-share", field: "link_share", description: "link sharing", + allowed: []string{"closed", "tenant-readable", "tenant-editable", "anyone-readable"}, + setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.LinkShare = value }, + responseValue: func(settings memberSettingsResponse) *string { return settings.LinkShare }, + }, + { + flag: "manage-collaborators-by", field: "manage_collaborators_by", description: "who can manage collaborators", + allowed: []string{"anyone", "same-tenant", "full-access"}, + setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.ManageCollaboratorsBy = value }, + responseValue: func(settings memberSettingsResponse) *string { return settings.ManageCollaboratorsBy }, + }, + { + flag: "comment-by", field: "comment_by", description: "who can comment", + allowed: []string{"viewer", "editor"}, + setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.CommentBy = value }, + responseValue: func(settings memberSettingsResponse) *string { return settings.CommentBy }, + }, + { + flag: "copy-download-by", field: "copy_download_by", description: "who can copy, print, or download", + allowed: []string{"viewer", "editor", "full-access"}, + setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.CopyDownloadBy = value }, + responseValue: func(settings memberSettingsResponse) *string { return settings.CopyDownloadBy }, + }, +} + +type memberSettingChangeResponse struct { + Field string `json:"field"` + Before *string `json:"before,omitempty"` + After *string `json:"after,omitempty"` +} + +type memberListAPIResponse struct { + Items *[]memberAPIRecord `json:"items"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token,omitempty"` +} + +type memberListOutput struct { + Items []memberOutput `json:"items"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token,omitempty"` +} + +type memberAddAPIResponse struct { + Member *memberAPIRecord `json:"member"` + Changed bool `json:"changed"` +} + +type memberAddOutput struct { + Member memberOutput `json:"member"` + Changed bool `json:"changed"` +} + +type memberUpdateAPIResponse struct { + Member *memberAPIRecord `json:"member"` + BeforeRole string `json:"before_role"` + AfterRole string `json:"after_role"` + Changed bool `json:"changed"` +} + +type memberUpdateOutput struct { + Member memberOutput `json:"member"` + BeforeRole string `json:"before_role"` + AfterRole string `json:"after_role"` + Changed bool `json:"changed"` +} + +type memberRemoveAPIResponse struct { + Member *memberAPIRecord `json:"member"` + Changed bool `json:"changed"` +} + +type memberRemoveOutput struct { + Member memberOutput `json:"member"` + Changed bool `json:"changed"` +} + +type memberSettingsGetAPIResponse struct { + Settings *memberSettingsResponse `json:"settings"` +} + +type memberSettingsGetOutput struct { + Settings memberSettingsResponse `json:"settings"` +} + +type memberSettingsSetAPIResponse struct { + Settings *memberSettingsResponse `json:"settings"` + Changes *[]memberSettingChangeResponse `json:"changes"` + Changed bool `json:"changed"` +} + +type memberSettingsSetOutput struct { + Settings memberSettingsResponse `json:"settings"` + Changes []memberSettingChangeResponse `json:"changes"` + Changed bool `json:"changed"` +} + +func memberAppID(rctx *common.RuntimeContext) string { + return strings.TrimSpace(rctx.Str("app-id")) +} + +func validateMemberAppID(rctx *common.RuntimeContext) error { + appID := memberAppID(rctx) + if appID == "" { + return appsValidationParamError("--app-id", "--app-id is required"). + WithHint("list your Miaoda apps with `lark-cli apps +list`") + } + if strings.HasPrefix(appID, "cli_") { + return appsValidationParamError("--app-id", "--app-id must be a Miaoda app_id, not a credential app id"). + WithHint("pass the app_... value returned by `lark-cli apps +list`, not the cli_... credential app id") + } + if !strings.HasPrefix(appID, "app_") || len(appID) == len("app_") { + return appsValidationParamError("--app-id", "--app-id must start with app_ and include an identifier"). + WithHint("list your Miaoda apps with `lark-cli apps +list`, then pass its app_id") + } + for _, r := range appID { + if unicode.IsSpace(r) || unicode.IsControl(r) || r == '/' || r == '\\' { + return appsValidationParamError("--app-id", "--app-id must not contain slashes, whitespace, or control characters"). + WithHint("pass one app_... identifier exactly as returned by `lark-cli apps +list`") + } + } + if err := validate.ResourceName(appID, "--app-id"); err != nil { + return appsValidationParamError("--app-id", "invalid --app-id: %v", err). + WithCause(err). + WithHint("pass one app_... identifier exactly as returned by `lark-cli apps +list`") + } + return nil +} + +func buildMemberListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) { + pageSize := rctx.Int("page-size") + if pageSize == 0 && !rctx.Changed("page-size") { + pageSize = defaultMemberPageSize + } + if pageSize < 1 || pageSize > maxMemberPageSize { + return nil, appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxMemberPageSize). + WithHint("use --page-size between 1 and 100") + } + + params := map[string]interface{}{"page_size": pageSize} + if role := strings.TrimSpace(rctx.Str("role")); role != "" { + if !memberStringAllowed(role, memberRoles) { + return nil, appsValidationParamError("--role", "--role must be one of: view, edit, full_access"). + WithHint("omit --role to list every collaborator role") + } + params["role"] = role + } + if memberType := strings.TrimSpace(rctx.Str("member-type")); memberType != "" { + if !memberStringAllowed(memberType, memberListTypes) { + return nil, appsValidationParamError("--member-type", "--member-type must be one of: user, department, chat"). + WithHint("omit --member-type to list every collaborator type") + } + params["member_type"] = memberType + } + if pageToken := strings.TrimSpace(rctx.Str("page-token")); pageToken != "" { + params["page_token"] = pageToken + } + return params, nil +} + +func buildMemberIdentity(memberType, memberID string) (memberIdentityRequest, error) { + memberType = strings.TrimSpace(memberType) + memberID = strings.TrimSpace(memberID) + if !memberStringAllowed(memberType, memberWriteTypes) { + return memberIdentityRequest{}, appsValidationParamError("--member-type", "--member-type must be one of: openid, openchat, opendepartmentid"). + WithHint("choose the type that matches the open ID you are passing") + } + if memberID == "" { + return memberIdentityRequest{}, appsValidationParamError("--member-id", "--member-id is required"). + WithHint("resolve the collaborator to a user, chat, or department open ID first") + } + + prefix := map[string]string{ + "openid": "ou_", + "openchat": "oc_", + "opendepartmentid": "od-", + }[memberType] + if !strings.HasPrefix(memberID, prefix) || len(memberID) == len(prefix) { + return memberIdentityRequest{}, appsValidationParamError("--member-id", "--member-id for --member-type=%s must start with %s", memberType, prefix). + WithHint(fmt.Sprintf("pass the matching external ID: %s...; internal numeric IDs are not accepted", prefix)) + } + for _, r := range memberID { + if unicode.IsSpace(r) || unicode.IsControl(r) || strings.ContainsRune("?#%/\\", r) { + return memberIdentityRequest{}, appsValidationParamError("--member-id", "--member-id must not contain whitespace, control characters, or URL metacharacters"). + WithHint("pass one external open ID exactly as returned by Lark") + } + } + + switch memberType { + case "openid": + return memberIdentityRequest{UserOpenID: memberID}, nil + case "openchat": + return memberIdentityRequest{ChatID: memberID}, nil + case "opendepartmentid": + return memberIdentityRequest{DepartmentID: memberID}, nil + default: + return memberIdentityRequest{}, appsValidationParamError("--member-type", "--member-type has no typed request mapping"). + WithHint("choose one of: openid, openchat, opendepartmentid") + } +} + +func memberListURL(rctx *common.RuntimeContext) string { + return fmt.Sprintf(memberListPath, validate.EncodePathSegment(memberAppID(rctx))) +} + +func memberRemoveURL(rctx *common.RuntimeContext) string { + return fmt.Sprintf(memberRemovePath, validate.EncodePathSegment(memberAppID(rctx))) +} + +func memberSettingsURL(rctx *common.RuntimeContext) string { + return fmt.Sprintf(memberSettingsPath, validate.EncodePathSegment(memberAppID(rctx))) +} + +func buildMemberAddRequest(rctx *common.RuntimeContext) (memberAddRequest, error) { + identity, err := buildMemberIdentity(rctx.Str("member-type"), rctx.Str("member-id")) + if err != nil { + return memberAddRequest{}, err + } + req := memberAddRequest{ + memberIdentityRequest: identity, + Role: strings.TrimSpace(rctx.Str("perm")), + } + if rctx.Changed("need-notification") { + needNotification := rctx.Bool("need-notification") + req.NeedNotification = &needNotification + } + return req, nil +} + +func buildMemberUpdateRequest(rctx *common.RuntimeContext) (memberUpdateRequest, error) { + identity, err := buildMemberIdentity(rctx.Str("member-type"), rctx.Str("member-id")) + if err != nil { + return memberUpdateRequest{}, err + } + return memberUpdateRequest{ + memberIdentityRequest: identity, + Role: strings.TrimSpace(rctx.Str("perm")), + }, nil +} + +func buildMemberRemoveRequest(rctx *common.RuntimeContext) (memberRemoveRequest, error) { + identity, err := buildMemberIdentity(rctx.Str("member-type"), rctx.Str("member-id")) + if err != nil { + return memberRemoveRequest{}, err + } + return memberRemoveRequest{memberIdentityRequest: identity}, nil +} + +func buildMemberSettingsUpdateRequest(rctx *common.RuntimeContext) (memberSettingsUpdateRequest, error) { + var req memberSettingsUpdateRequest + for _, spec := range memberSettingSpecs { + if !rctx.Changed(spec.flag) { + continue + } + value := strings.TrimSpace(rctx.Str(spec.flag)) + if !memberStringAllowed(value, spec.allowed) { + return memberSettingsUpdateRequest{}, appsValidationParamError("--"+spec.flag, "invalid value %q for --%s", value, spec.flag). + WithHint("choose one of the documented setting values shown by --help") + } + spec.setRequest(&req, &value) + } + return req, nil +} + +func validateMemberMutation(rctx *common.RuntimeContext, requirePerm bool) error { + if err := validateMemberAppID(rctx); err != nil { + return err + } + if _, err := buildMemberIdentity(rctx.Str("member-type"), rctx.Str("member-id")); err != nil { + return err + } + if requirePerm { + perm := strings.TrimSpace(rctx.Str("perm")) + if !memberStringAllowed(perm, memberRoles) { + return appsValidationParamError("--perm", "--perm must be one of: view, edit, full_access"). + WithHint("choose the collaborator permission explicitly") + } + } + return nil +} + +func validateMemberSettingsSet(rctx *common.RuntimeContext) error { + if err := validateMemberAppID(rctx); err != nil { + return err + } + for _, spec := range memberSettingSpecs { + if rctx.Changed(spec.flag) { + _, err := buildMemberSettingsUpdateRequest(rctx) + return err + } + } + return appsValidationError("at least one collaborator setting must be provided"). + WithParams( + appsInvalidParam("--external-access", "not provided"), + appsInvalidParam("--external-invite", "not provided"), + appsInvalidParam("--link-share", "not provided"), + appsInvalidParam("--manage-collaborators-by", "not provided"), + appsInvalidParam("--comment-by", "not provided"), + appsInvalidParam("--copy-download-by", "not provided"), + ). + WithHint("pass at least one setting flag; omitted settings remain unchanged") +} + +func memberStringAllowed(value string, allowed []string) bool { + for _, item := range allowed { + if value == item { + return true + } + } + return false +} + +func decodeMemberAPIData(data map[string]interface{}, out interface{}) error { + if data == nil { + return memberInvalidResponse("member API response data must be an object") + } + raw, err := json.Marshal(data) + if err != nil { + return memberInvalidResponse("member API response could not be decoded").WithCause(err) + } + if err := json.Unmarshal(raw, out); err != nil { + return memberInvalidResponse("member API response has an invalid shape").WithCause(err) + } + return nil +} + +func projectMemberListData(data map[string]interface{}) (memberListOutput, error) { + var decoded memberListAPIResponse + if err := decodeMemberAPIData(data, &decoded); err != nil { + return memberListOutput{}, err + } + if decoded.Items == nil { + return memberListOutput{}, memberInvalidResponse("member list response is missing items") + } + if decoded.HasMore && strings.TrimSpace(decoded.PageToken) == "" { + return memberListOutput{}, memberInvalidResponse("member list response has_more=true but page_token is missing") + } + items := make([]memberOutput, 0, len(*decoded.Items)) + for _, raw := range *decoded.Items { + item, err := projectMemberRecord(raw) + if err != nil { + return memberListOutput{}, err + } + items = append(items, item) + } + return memberListOutput{ + Items: items, + HasMore: decoded.HasMore, + PageToken: decoded.PageToken, + }, nil +} + +func projectMemberAddData(data map[string]interface{}) (memberAddOutput, error) { + var decoded memberAddAPIResponse + if err := decodeMemberAPIData(data, &decoded); err != nil { + return memberAddOutput{}, err + } + if decoded.Member == nil { + return memberAddOutput{}, memberInvalidResponse("member add response is missing member") + } + member, err := projectMemberRecord(*decoded.Member) + if err != nil { + return memberAddOutput{}, err + } + return memberAddOutput{Member: member, Changed: decoded.Changed}, nil +} + +func projectMemberUpdateData(data map[string]interface{}) (memberUpdateOutput, error) { + var decoded memberUpdateAPIResponse + if err := decodeMemberAPIData(data, &decoded); err != nil { + return memberUpdateOutput{}, err + } + if decoded.Member == nil { + return memberUpdateOutput{}, memberInvalidResponse("member update response is missing member") + } + if !memberStringAllowed(decoded.BeforeRole, memberRoles) || !memberStringAllowed(decoded.AfterRole, memberRoles) { + return memberUpdateOutput{}, memberInvalidResponse("member update response contains an unsupported role transition") + } + member, err := projectMemberRecord(*decoded.Member) + if err != nil { + return memberUpdateOutput{}, err + } + return memberUpdateOutput{ + Member: member, BeforeRole: decoded.BeforeRole, AfterRole: decoded.AfterRole, Changed: decoded.Changed, + }, nil +} + +func projectMemberRemoveData(data map[string]interface{}) (memberRemoveOutput, error) { + var decoded memberRemoveAPIResponse + if err := decodeMemberAPIData(data, &decoded); err != nil { + return memberRemoveOutput{}, err + } + if decoded.Member == nil { + return memberRemoveOutput{}, memberInvalidResponse("member remove response is missing member") + } + member, err := projectMemberRecord(*decoded.Member) + if err != nil { + return memberRemoveOutput{}, err + } + return memberRemoveOutput{Member: member, Changed: decoded.Changed}, nil +} + +func projectMemberSettingsGetData(data map[string]interface{}) (memberSettingsGetOutput, error) { + var decoded memberSettingsGetAPIResponse + if err := decodeMemberAPIData(data, &decoded); err != nil { + return memberSettingsGetOutput{}, err + } + if decoded.Settings == nil { + return memberSettingsGetOutput{}, memberInvalidResponse("member settings response is missing settings") + } + if err := validateMemberSettingsResponse(*decoded.Settings); err != nil { + return memberSettingsGetOutput{}, err + } + return memberSettingsGetOutput{Settings: *decoded.Settings}, nil +} + +func projectMemberSettingsSetData(data map[string]interface{}) (memberSettingsSetOutput, error) { + var decoded memberSettingsSetAPIResponse + if err := decodeMemberAPIData(data, &decoded); err != nil { + return memberSettingsSetOutput{}, err + } + if decoded.Settings == nil { + return memberSettingsSetOutput{}, memberInvalidResponse("member settings update response is missing settings") + } + if decoded.Changes == nil { + return memberSettingsSetOutput{}, memberInvalidResponse("member settings update response is missing changes") + } + if err := validateMemberSettingsResponse(*decoded.Settings); err != nil { + return memberSettingsSetOutput{}, err + } + if err := validateMemberSettingChanges(*decoded.Changes); err != nil { + return memberSettingsSetOutput{}, err + } + return memberSettingsSetOutput{ + Settings: *decoded.Settings, Changes: *decoded.Changes, Changed: decoded.Changed, + }, nil +} + +func projectMemberRecord(raw memberAPIRecord) (memberOutput, error) { + if !memberStringAllowed(raw.Role, memberRoles) { + return memberOutput{}, memberInvalidResponse("member response contains an unsupported role") + } + + typedCount := 0 + for _, value := range []*string{raw.UserOpenID, raw.DepartmentID, raw.ChatID} { + if value != nil { + typedCount++ + } + } + if typedCount != 1 { + return memberOutput{}, memberInvalidResponse("member response must contain exactly one typed external ID") + } + + var memberID, prefix string + switch raw.MemberType { + case "user": + if raw.UserOpenID == nil { + return memberOutput{}, memberInvalidResponse("member_type does not match the typed external ID") + } + memberID, prefix = *raw.UserOpenID, "ou_" + case "department": + if raw.DepartmentID == nil { + return memberOutput{}, memberInvalidResponse("member_type does not match the typed external ID") + } + memberID, prefix = *raw.DepartmentID, "od-" + case "chat": + if raw.ChatID == nil { + return memberOutput{}, memberInvalidResponse("member_type does not match the typed external ID") + } + memberID, prefix = *raw.ChatID, "oc_" + default: + return memberOutput{}, memberInvalidResponse("member response contains an unsupported member_type") + } + if !validExternalMemberID(memberID, prefix) { + return memberOutput{}, memberInvalidResponse("member response contains a malformed external ID; refusing to expose it") + } + + return memberOutput{ + MemberType: raw.MemberType, + MemberID: memberID, + Name: raw.Name, + Role: raw.Role, + }, nil +} + +func validExternalMemberID(value, prefix string) bool { + if value != strings.TrimSpace(value) || !strings.HasPrefix(value, prefix) || len(value) == len(prefix) { + return false + } + for _, r := range value { + if unicode.IsSpace(r) || unicode.IsControl(r) || strings.ContainsRune("?#%/\\", r) { + return false + } + } + return true +} + +func validateMemberSettingsResponse(settings memberSettingsResponse) error { + for _, spec := range memberSettingSpecs { + value := spec.responseValue(settings) + if value != nil && !memberStringAllowed(*value, spec.allowed) { + return memberInvalidResponse("member settings response contains an unsupported %s value", spec.field) + } + } + return nil +} + +func validateMemberSettingChanges(changes []memberSettingChangeResponse) error { + for _, change := range changes { + spec := memberSettingSpecForField(change.Field) + if spec == nil { + return memberInvalidResponse("member settings response contains an unsupported changed field") + } + for _, value := range []*string{change.Before, change.After} { + if value != nil && !memberStringAllowed(*value, spec.allowed) { + return memberInvalidResponse("member settings response contains an unsupported changed value") + } + } + } + return nil +} + +func memberSettingSpecForField(field string) *memberSettingSpec { + for index := range memberSettingSpecs { + if memberSettingSpecs[index].field == field { + return &memberSettingSpecs[index] + } + } + return nil +} + +func memberSettingsSetFlags() []common.Flag { + flags := make([]common.Flag, 0, len(memberSettingSpecs)+1) + flags = append(flags, common.Flag{Name: "app-id", Desc: "Miaoda app ID (app_...)"}) + for _, spec := range memberSettingSpecs { + flags = append(flags, common.Flag{ + Name: spec.flag, + Desc: spec.description, + Enum: append([]string(nil), spec.allowed...), + }) + } + return flags +} + +func memberInvalidResponse(format string, args ...interface{}) *errs.InternalError { + return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...). + WithHint("retry the operation; do not use any member ID from this response") +} + +func memberDisplayValue(value string) string { + value = validate.SanitizeForTerminal(value) + return strings.TrimSpace(strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(value)) +} diff --git a/shortcuts/apps/apps_member_common_test.go b/shortcuts/apps/apps_member_common_test.go new file mode 100644 index 0000000000..c3f972f11a --- /dev/null +++ b/shortcuts/apps/apps_member_common_test.go @@ -0,0 +1,451 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +type appsMemberDryRunCall struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + Body map[string]interface{} `json:"body"` +} + +func appsMemberDryRunCallFor(t *testing.T, shortcut common.Shortcut, values map[string]string) appsMemberDryRunCall { + t.Helper() + if shortcut.DryRun == nil { + t.Fatalf("%s DryRun must be registered", shortcut.Command) + } + rctx := newAppsMemberRuntime(t, shortcut, values) + if shortcut.Validate != nil { + if err := shortcut.Validate(context.Background(), rctx); err != nil { + t.Fatalf("%s validation: %v", shortcut.Command, err) + } + } + raw, err := json.Marshal(shortcut.DryRun(context.Background(), rctx)) + if err != nil { + t.Fatalf("marshal %s dry-run: %v", shortcut.Command, err) + } + var envelope struct { + API []appsMemberDryRunCall `json:"api"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatalf("decode %s dry-run: %v", shortcut.Command, err) + } + if len(envelope.API) != 1 { + t.Fatalf("%s dry-run calls = %d, want 1: %s", shortcut.Command, len(envelope.API), raw) + } + return envelope.API[0] +} + +func newAppsMemberRuntime(t *testing.T, shortcut common.Shortcut, values map[string]string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: shortcut.Command} + for _, flag := range shortcut.Flags { + switch flag.Type { + case "bool": + cmd.Flags().Bool(flag.Name, flag.Default == "true", flag.Desc) + case "int": + defaultValue := 0 + if flag.Default != "" { + parsed, err := strconv.Atoi(flag.Default) + if err != nil { + t.Fatalf("parse --%s default %q: %v", flag.Name, flag.Default, err) + } + defaultValue = parsed + } + cmd.Flags().Int(flag.Name, defaultValue, flag.Desc) + default: + cmd.Flags().String(flag.Name, flag.Default, flag.Desc) + } + } + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s=%q: %v", name, value, err) + } + } + return common.TestNewRuntimeContext(cmd, &core.CliConfig{}) +} + +func requireAppsMemberValidationError(t *testing.T, err error, param string) *errs.ValidationError { + t.Helper() + if err == nil { + t.Fatal("validation error = nil") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("validation error type = %T, want *errs.ValidationError: %v", err, err) + } + if validationErr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("validation subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument) + } + if validationErr.Param != param { + t.Errorf("validation param = %q, want %q", validationErr.Param, param) + } + if validationErr.Hint == "" { + t.Error("validation hint must be actionable") + } + return validationErr +} + +func TestAppsMemberAPIErrorNormalization(t *testing.T) { + tests := []struct { + name string + code int + wantSubtype errs.Subtype + wantMessage string + wantHint string + }{ + { + name: "internal invalid page token", code: 40004, wantSubtype: errs.SubtypeInvalidParameters, + wantHint: "discard --page-token and restart from the first page", + }, + { + name: "OpenAPI invalid page token", code: 3340004, wantSubtype: errs.SubtypeInvalidParameters, + wantHint: "discard --page-token and restart from the first page", + }, + { + name: "internal feature not available", code: 40005, wantSubtype: errs.SubtypeFeatureNotAvailable, + wantMessage: "Collaborator management is not available for this app via lark-cli.", + wantHint: "Open this app in Miaoda and manage collaborators from its permission settings.", + }, + { + name: "OpenAPI feature not available", code: 3340005, wantSubtype: errs.SubtypeFeatureNotAvailable, + wantMessage: "Collaborator management is not available for this app via lark-cli.", + wantHint: "Open this app in Miaoda and manage collaborators from its permission settings.", + }, + {name: "internal app not found", code: 40400, wantSubtype: errs.SubtypeNotFound}, + {name: "OpenAPI app not found", code: 3340400, wantSubtype: errs.SubtypeNotFound}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + input := errs.NewAPIError(errs.SubtypeUnknown, "server message").WithCode(tc.code).WithLogID("log-member") + got := normalizeMemberAPIError(input) + problem, ok := errs.ProblemOf(got) + if !ok { + t.Fatalf("normalizeMemberAPIError() = %T, want typed problem", got) + } + if problem.Code != tc.code || problem.Subtype != tc.wantSubtype || problem.LogID != "log-member" || problem.Retryable { + t.Fatalf("problem = %+v", problem) + } + if tc.wantMessage != "" && problem.Message != tc.wantMessage { + t.Fatalf("message = %q, want %q", problem.Message, tc.wantMessage) + } + if problem.Hint != tc.wantHint { + t.Fatalf("hint = %q, want %q", problem.Hint, tc.wantHint) + } + }) + } +} + +func TestAppsMemberFlagsExposeExactEnums(t *testing.T) { + tests := []struct { + shortcut common.Shortcut + flag string + want []string + }{ + {AppsMemberList, "role", []string{"view", "edit", "full_access"}}, + {AppsMemberList, "member-type", []string{"user", "department", "chat"}}, + {AppsMemberAdd, "member-type", []string{"openid", "openchat", "opendepartmentid"}}, + {AppsMemberAdd, "perm", []string{"view", "edit", "full_access"}}, + {AppsMemberUpdate, "member-type", []string{"openid", "openchat", "opendepartmentid"}}, + {AppsMemberUpdate, "perm", []string{"view", "edit", "full_access"}}, + {AppsMemberRemove, "member-type", []string{"openid", "openchat", "opendepartmentid"}}, + {AppsMemberSettingsSet, "external-access", []string{"enabled", "disabled"}}, + {AppsMemberSettingsSet, "external-invite", []string{"enabled", "disabled"}}, + {AppsMemberSettingsSet, "link-share", []string{"closed", "tenant-readable", "tenant-editable", "anyone-readable"}}, + {AppsMemberSettingsSet, "manage-collaborators-by", []string{"anyone", "same-tenant", "full-access"}}, + {AppsMemberSettingsSet, "comment-by", []string{"viewer", "editor"}}, + {AppsMemberSettingsSet, "copy-download-by", []string{"viewer", "editor", "full-access"}}, + } + + for _, tc := range tests { + t.Run(tc.shortcut.Command+"/"+tc.flag, func(t *testing.T) { + var got []string + for _, flag := range tc.shortcut.Flags { + if flag.Name == tc.flag { + got = flag.Enum + break + } + } + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("%s --%s enum = %#v, want %#v", tc.shortcut.Command, tc.flag, got, tc.want) + } + }) + } +} + +func TestAppsMemberAppIDValidationIsOwnedByShortcut(t *testing.T) { + for _, shortcut := range []common.Shortcut{ + AppsMemberList, AppsMemberAdd, AppsMemberUpdate, AppsMemberRemove, + AppsMemberSettingsGet, AppsMemberSettingsSet, + } { + t.Run(shortcut.Command, func(t *testing.T) { + for _, flag := range shortcut.Flags { + if flag.Name == "app-id" { + if flag.Required { + t.Fatal("app-id must use shortcut validation so errors include param and hint") + } + return + } + } + t.Fatal("app-id flag is missing") + }) + } +} + +func TestAppsMemberPublicCopyIsGeneric(t *testing.T) { + tests := []struct { + shortcut common.Shortcut + values map[string]string + }{ + {AppsMemberList, map[string]string{"app-id": "app_x"}}, + {AppsMemberAdd, map[string]string{"app-id": "app_x", "member-type": "openid", "member-id": "ou_user", "perm": "view"}}, + {AppsMemberUpdate, map[string]string{"app-id": "app_x", "member-type": "openid", "member-id": "ou_user", "perm": "edit"}}, + {AppsMemberRemove, map[string]string{"app-id": "app_x", "member-type": "openid", "member-id": "ou_user"}}, + {AppsMemberSettingsGet, map[string]string{"app-id": "app_x"}}, + {AppsMemberSettingsSet, map[string]string{"app-id": "app_x", "external-access": "enabled"}}, + } + for _, tc := range tests { + t.Run(tc.shortcut.Command, func(t *testing.T) { + if strings.Contains(strings.ToLower(tc.shortcut.Description), "creative") { + t.Fatalf("description exposes an internal app mode: %q", tc.shortcut.Description) + } + call := appsMemberDryRunCallFor(t, tc.shortcut, tc.values) + raw, err := json.Marshal(call) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(raw)), "creative") { + t.Fatalf("dry-run exposes an internal app mode: %s", raw) + } + }) + } +} + +func TestAppsMemberListValidationAndParams(t *testing.T) { + valid := newAppsMemberRuntime(t, AppsMemberList, map[string]string{ + "app-id": " app_test ", "role": "edit", "member-type": "chat", "page-size": "100", "page-token": " next-token ", + }) + if AppsMemberList.Validate == nil { + t.Fatal("member-list Validate must be registered") + } + if err := AppsMemberList.Validate(context.Background(), valid); err != nil { + t.Fatalf("valid member-list flags: %v", err) + } + params, err := buildMemberListParams(valid) + if err != nil { + t.Fatalf("build params: %v", err) + } + want := map[string]interface{}{ + "role": "edit", "member_type": "chat", "page_size": 100, "page_token": "next-token", + } + if !reflect.DeepEqual(params, want) { + t.Fatalf("params = %#v, want %#v", params, want) + } + + for _, pageSize := range []string{"0", "101"} { + rctx := newAppsMemberRuntime(t, AppsMemberList, map[string]string{"app-id": "app_test", "page-size": pageSize}) + err := AppsMemberList.Validate(context.Background(), rctx) + requireAppsMemberValidationError(t, err, "--page-size") + } +} + +func TestAppsMemberIdentityValidationMapsExactlyOneTypedField(t *testing.T) { + tests := []struct { + memberType string + memberID string + want memberIdentityRequest + }{ + {memberType: "openid", memberID: "ou_member", want: memberIdentityRequest{UserOpenID: "ou_member"}}, + {memberType: "openchat", memberID: "oc_member", want: memberIdentityRequest{ChatID: "oc_member"}}, + {memberType: "opendepartmentid", memberID: "od-member", want: memberIdentityRequest{DepartmentID: "od-member"}}, + } + for _, tc := range tests { + t.Run(tc.memberType, func(t *testing.T) { + got, err := buildMemberIdentity(tc.memberType, tc.memberID) + if err != nil { + t.Fatalf("buildMemberIdentity: %v", err) + } + if got != tc.want { + t.Fatalf("identity = %#v, want %#v", got, tc.want) + } + }) + } + + invalid := []struct { + memberType string + memberID string + param string + }{ + {memberType: "openid", memberID: "12345", param: "--member-id"}, + {memberType: "openchat", memberID: "ou_member", param: "--member-id"}, + {memberType: "opendepartmentid", memberID: "oc_member", param: "--member-id"}, + {memberType: "mystery", memberID: "ou_member", param: "--member-type"}, + {memberType: "openid", memberID: "", param: "--member-id"}, + } + for _, tc := range invalid { + t.Run("reject/"+tc.memberType+"/"+tc.memberID, func(t *testing.T) { + _, err := buildMemberIdentity(tc.memberType, tc.memberID) + requireAppsMemberValidationError(t, err, tc.param) + }) + } +} + +func TestAppsMemberMutationValidation(t *testing.T) { + valid := []struct { + shortcut common.Shortcut + values map[string]string + }{ + {AppsMemberAdd, map[string]string{"app-id": " app_test ", "member-type": "openid", "member-id": "ou_member", "perm": "view", "need-notification": "false"}}, + {AppsMemberUpdate, map[string]string{"app-id": "app_test", "member-type": "openchat", "member-id": "oc_member", "perm": "full_access"}}, + {AppsMemberRemove, map[string]string{"app-id": "app_test", "member-type": "opendepartmentid", "member-id": "od-member"}}, + } + for _, tc := range valid { + t.Run(tc.shortcut.Command, func(t *testing.T) { + if tc.shortcut.Validate == nil { + t.Fatal("Validate must be registered") + } + if err := tc.shortcut.Validate(context.Background(), newAppsMemberRuntime(t, tc.shortcut, tc.values)); err != nil { + t.Fatalf("valid mutation flags: %v", err) + } + }) + } + + rctx := newAppsMemberRuntime(t, AppsMemberAdd, map[string]string{ + "app-id": "cli_credential", "member-type": "openid", "member-id": "ou_member", "perm": "view", + }) + requireAppsMemberValidationError(t, AppsMemberAdd.Validate(context.Background(), rctx), "--app-id") +} + +func TestAppsMemberAppIDValidationPreservesResourceNameCause(t *testing.T) { + rctx := newAppsMemberRuntime(t, AppsMemberList, map[string]string{"app-id": "app_test?query"}) + err := AppsMemberList.Validate(context.Background(), rctx) + validationErr := requireAppsMemberValidationError(t, err, "--app-id") + cause := errors.Unwrap(validationErr) + if cause == nil { + t.Fatal("resource-name validation cause = nil") + } + if !strings.Contains(cause.Error(), "invalid characters") { + t.Fatalf("resource-name validation cause = %q, want invalid characters", cause) + } +} + +func TestAppsMemberSettingsSetRequiresAtLeastOneExplicitField(t *testing.T) { + empty := newAppsMemberRuntime(t, AppsMemberSettingsSet, map[string]string{"app-id": "app_test"}) + if AppsMemberSettingsSet.Validate == nil { + t.Fatal("member-settings-set Validate must be registered") + } + err := AppsMemberSettingsSet.Validate(context.Background(), empty) + if err == nil { + t.Fatal("settings-set without changes must fail") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Hint == "" { + t.Fatalf("settings-set error = %#v, want actionable invalid_argument", err) + } + + for _, field := range []struct{ name, value string }{ + {name: "external-access", value: "enabled"}, + {name: "external-invite", value: "disabled"}, + {name: "link-share", value: "tenant-readable"}, + {name: "manage-collaborators-by", value: "same-tenant"}, + {name: "comment-by", value: "viewer"}, + {name: "copy-download-by", value: "full-access"}, + } { + t.Run(field.name, func(t *testing.T) { + rctx := newAppsMemberRuntime(t, AppsMemberSettingsSet, map[string]string{"app-id": "app_test", field.name: field.value}) + if err := AppsMemberSettingsSet.Validate(context.Background(), rctx); err != nil { + t.Fatalf("explicit --%s should validate: %v", field.name, err) + } + }) + } +} + +func TestAppsMemberDryRunRequestsUseExactRoutesAndTypedBodies(t *testing.T) { + t.Run("list", func(t *testing.T) { + call := appsMemberDryRunCallFor(t, AppsMemberList, map[string]string{ + "app-id": " app_报告 ", "role": "view", "member-type": "user", "page-size": "25", "page-token": "opaque", + }) + if call.Method != "GET" || call.URL != "/open-apis/spark/v1/apps/app_%E6%8A%A5%E5%91%8A/members" { + t.Fatalf("list request = %s %s", call.Method, call.URL) + } + want := map[string]interface{}{"role": "view", "member_type": "user", "page_size": float64(25), "page_token": "opaque"} + if !reflect.DeepEqual(call.Params, want) || call.Body != nil { + t.Fatalf("list params/body = %#v / %#v, want %#v / nil", call.Params, call.Body, want) + } + }) + + mutations := []struct { + name string + shortcut common.Shortcut + values map[string]string + method string + url string + body map[string]interface{} + }{ + { + name: "add-user-with-explicit-false-notification", shortcut: AppsMemberAdd, + values: map[string]string{"app-id": "app_x", "member-type": "openid", "member-id": "ou_member", "perm": "edit", "need-notification": "false"}, + method: "POST", url: "/open-apis/spark/v1/apps/app_x/members", + body: map[string]interface{}{"user_open_id": "ou_member", "role": "edit", "need_notification": false}, + }, + { + name: "add-chat-omits-notification", shortcut: AppsMemberAdd, + values: map[string]string{"app-id": "app_x", "member-type": "openchat", "member-id": "oc_member", "perm": "view"}, + method: "POST", url: "/open-apis/spark/v1/apps/app_x/members", + body: map[string]interface{}{"chat_id": "oc_member", "role": "view"}, + }, + { + name: "update-department", shortcut: AppsMemberUpdate, + values: map[string]string{"app-id": "app_x", "member-type": "opendepartmentid", "member-id": "od-member", "perm": "full_access"}, + method: "PATCH", url: "/open-apis/spark/v1/apps/app_x/members", + body: map[string]interface{}{"department_id": "od-member", "role": "full_access"}, + }, + { + name: "remove-user", shortcut: AppsMemberRemove, + values: map[string]string{"app-id": "app_x", "member-type": "openid", "member-id": "ou_member"}, + method: "POST", url: "/open-apis/spark/v1/apps/app_x/members/remove", + body: map[string]interface{}{"user_open_id": "ou_member"}, + }, + } + for _, tc := range mutations { + t.Run(tc.name, func(t *testing.T) { + call := appsMemberDryRunCallFor(t, tc.shortcut, tc.values) + if call.Method != tc.method || call.URL != tc.url || !reflect.DeepEqual(call.Body, tc.body) || call.Params != nil { + t.Fatalf("request = %s %s params=%#v body=%#v, want %s %s params=nil body=%#v", call.Method, call.URL, call.Params, call.Body, tc.method, tc.url, tc.body) + } + }) + } + + t.Run("settings-get", func(t *testing.T) { + call := appsMemberDryRunCallFor(t, AppsMemberSettingsGet, map[string]string{"app-id": "app_x"}) + if call.Method != "GET" || call.URL != "/open-apis/spark/v1/apps/app_x/member-settings" || call.Params != nil || call.Body != nil { + t.Fatalf("settings get request = %#v", call) + } + }) + + t.Run("settings-set-partial", func(t *testing.T) { + call := appsMemberDryRunCallFor(t, AppsMemberSettingsSet, map[string]string{ + "app-id": "app_x", "external-access": "disabled", "comment-by": "editor", + }) + want := map[string]interface{}{"external_access": "disabled", "comment_by": "editor"} + if call.Method != "PATCH" || call.URL != "/open-apis/spark/v1/apps/app_x/member-settings" || !reflect.DeepEqual(call.Body, want) { + t.Fatalf("settings set request = %#v, want body %#v", call, want) + } + }) +} diff --git a/shortcuts/apps/apps_member_response_test.go b/shortcuts/apps/apps_member_response_test.go new file mode 100644 index 0000000000..ee007b3cee --- /dev/null +++ b/shortcuts/apps/apps_member_response_test.go @@ -0,0 +1,395 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" + "github.com/tidwall/gjson" +) + +func newAppsMemberAPIRuntime(t *testing.T, shortcut common.Shortcut, values map[string]string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + cfg := &core.CliConfig{ + AppID: "test-member-client", + AppSecret: "test-member-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_test_operator", + } + factory, stdout, _, registry := cmdutil.TestFactory(t, cfg) + cmd := &cobra.Command{Use: shortcut.Command} + cmd.SetContext(context.Background()) + for _, flag := range shortcut.Flags { + switch flag.Type { + case "bool": + cmd.Flags().Bool(flag.Name, flag.Default == "true", flag.Desc) + case "int": + defaultValue := 0 + if flag.Default != "" { + parsed, err := strconv.Atoi(flag.Default) + if err != nil { + t.Fatalf("parse --%s default %q: %v", flag.Name, flag.Default, err) + } + defaultValue = parsed + } + cmd.Flags().Int(flag.Name, defaultValue, flag.Desc) + default: + cmd.Flags().String(flag.Name, flag.Default, flag.Desc) + } + } + cmd.Flags().String("format", "json", "") + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s=%q: %v", name, value, err) + } + } + rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + rctx.Format = "json" + return rctx, stdout, registry +} + +func requireAppsMemberInvalidResponse(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("invalid response error = nil") + } + var internalErr *errs.InternalError + if !errors.As(err, &internalErr) { + t.Fatalf("invalid response error type = %T, want *errs.InternalError: %v", err, err) + } + if internalErr.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("invalid response subtype = %q, want %q", internalErr.Subtype, errs.SubtypeInvalidResponse) + } +} + +func TestAppsMemberProjectionFlattensExternalTypedIDs(t *testing.T) { + user := "ou_user" + department := "od-department" + chat := "oc_chat" + tests := []struct { + name string + raw memberAPIRecord + want memberOutput + }{ + { + name: "user", + raw: memberAPIRecord{MemberType: "user", UserOpenID: &user, Name: "User", Role: "view"}, + want: memberOutput{MemberType: "user", MemberID: "ou_user", Name: "User", Role: "view"}, + }, + { + name: "department", + raw: memberAPIRecord{MemberType: "department", DepartmentID: &department, Role: "edit"}, + want: memberOutput{MemberType: "department", MemberID: "od-department", Role: "edit"}, + }, + { + name: "chat", + raw: memberAPIRecord{MemberType: "chat", ChatID: &chat, Name: "Chat", Role: "full_access"}, + want: memberOutput{MemberType: "chat", MemberID: "oc_chat", Name: "Chat", Role: "full_access"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := projectMemberRecord(tc.raw) + if err != nil { + t.Fatalf("projectMemberRecord: %v", err) + } + if got != tc.want { + t.Fatalf("projected member = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestAppsMemberProjectionFailsClosedOnMalformedTypedIDs(t *testing.T) { + user := "ou_user" + chat := "oc_chat" + internal := "123456789" + empty := "" + tests := []struct { + name string + raw memberAPIRecord + }{ + {name: "missing typed ID", raw: memberAPIRecord{MemberType: "user", Role: "view"}}, + {name: "multiple typed IDs", raw: memberAPIRecord{MemberType: "user", UserOpenID: &user, ChatID: &chat, Role: "view"}}, + {name: "member type and ID mismatch", raw: memberAPIRecord{MemberType: "user", ChatID: &chat, Role: "view"}}, + {name: "internal numeric user ID", raw: memberAPIRecord{MemberType: "user", UserOpenID: &internal, Role: "view"}}, + {name: "empty typed ID", raw: memberAPIRecord{MemberType: "chat", ChatID: &empty, Role: "view"}}, + {name: "unknown member type", raw: memberAPIRecord{MemberType: "unknown", UserOpenID: &user, Role: "view"}}, + {name: "unsupported role", raw: memberAPIRecord{MemberType: "user", UserOpenID: &user, Role: "owner"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := projectMemberRecord(tc.raw) + requireAppsMemberInvalidResponse(t, err) + }) + } +} + +func TestAppsMemberListExecuteUsesTypedProjectionWithoutLeakingRawFields(t *testing.T) { + rctx, stdout, registry := newAppsMemberAPIRuntime(t, AppsMemberList, map[string]string{ + "app-id": "app_x", "member-type": "user", "page-size": "20", + }) + stub := &httpmock.Stub{ + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/members", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "member_type": "user", "user_open_id": "ou_public", "name": "User", "role": "view", + "meta_token": "sensitive-internal-token", + }, + }, + "app": map[string]interface{}{"meta_token": "sensitive-internal-token"}, + "has_more": true, "page_token": "opaque-next", + }, + }, + } + registry.Register(stub) + if AppsMemberList.Execute == nil { + t.Fatal("member-list Execute must be registered") + } + if err := AppsMemberList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("member-list Execute: %v", err) + } + registry.Verify(t) + if got := stub.CapturedBody; len(got) != 0 { + t.Fatalf("GET request body = %q, want empty", got) + } + out := stdout.String() + for path, want := range map[string]string{ + "data.items.0.member_type": "user", + "data.items.0.member_id": "ou_public", + "data.page_token": "opaque-next", + } { + if got := gjson.Get(out, path).String(); got != want { + t.Errorf("output %s = %q, want %q: %s", path, got, want, out) + } + } + if gjson.Get(out, "data.app").Exists() { + t.Fatalf("member output unexpectedly contains app: %s", out) + } + for _, forbidden := range []string{"collaborator_id", "user_open_id", "department_id", "chat_id", "123456789", "meta_token", "sensitive-internal-token"} { + if strings.Contains(out, forbidden) { + t.Errorf("output exposed typed/internal field %q: %s", forbidden, out) + } + } +} + +func TestAppsMemberListExecuteNeverLeaksMetaTokenAcrossFormats(t *testing.T) { + for _, format := range []string{"json", "table", "csv", "ndjson", "pretty"} { + t.Run(format, func(t *testing.T) { + rctx, stdout, registry := newAppsMemberAPIRuntime(t, AppsMemberList, map[string]string{ + "app-id": "app_x", "page-size": "20", + }) + rctx.Format = format + registry.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/members", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "member_type": "user", "user_open_id": "ou_public", "role": "view", + "meta_token": "sensitive-internal-token", + }, + }, + "app": map[string]interface{}{"meta_token": "sensitive-internal-token"}, + }, + }, + }) + + if err := AppsMemberList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("member-list Execute: %v", err) + } + registry.Verify(t) + out := stdout.String() + if gjson.Get(out, "data.app").Exists() { + t.Fatalf("%s output unexpectedly contains app: %s", format, out) + } + for _, forbidden := range []string{"meta_token", "sensitive-internal-token"} { + if strings.Contains(out, forbidden) { + t.Errorf("%s output exposed internal field %q: %s", format, forbidden, out) + } + } + }) + } +} + +func TestAppsMemberMutationExecuteProjectsResponses(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + values map[string]string + method string + url string + data map[string]interface{} + want map[string]string + wantBool map[string]bool + }{ + { + name: "add", shortcut: AppsMemberAdd, + values: map[string]string{"app-id": "app_x", "member-type": "openid", "member-id": "ou_added", "perm": "view"}, + method: "POST", url: "/open-apis/spark/v1/apps/app_x/members", + data: map[string]interface{}{"member": map[string]interface{}{"member_type": "user", "user_open_id": "ou_added", "role": "view"}, "changed": true}, + want: map[string]string{"data.member.member_id": "ou_added"}, wantBool: map[string]bool{"data.changed": true}, + }, + { + name: "update", shortcut: AppsMemberUpdate, + values: map[string]string{"app-id": "app_x", "member-type": "openchat", "member-id": "oc_updated", "perm": "edit"}, + method: "PATCH", url: "/open-apis/spark/v1/apps/app_x/members", + data: map[string]interface{}{"member": map[string]interface{}{"member_type": "chat", "chat_id": "oc_updated", "role": "edit"}, "before_role": "view", "after_role": "edit", "changed": true}, + want: map[string]string{"data.member.member_id": "oc_updated", "data.before_role": "view", "data.after_role": "edit"}, + }, + { + name: "remove", shortcut: AppsMemberRemove, + values: map[string]string{"app-id": "app_x", "member-type": "opendepartmentid", "member-id": "od-removed"}, + method: "POST", url: "/open-apis/spark/v1/apps/app_x/members/remove", + data: map[string]interface{}{"member": map[string]interface{}{"member_type": "department", "department_id": "od-removed", "role": "view"}, "changed": true}, + want: map[string]string{"data.member.member_id": "od-removed"}, wantBool: map[string]bool{"data.changed": true}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rctx, stdout, registry := newAppsMemberAPIRuntime(t, tc.shortcut, tc.values) + stub := &httpmock.Stub{Method: tc.method, URL: tc.url, Body: map[string]interface{}{"code": 0, "msg": "", "data": tc.data}} + registry.Register(stub) + if tc.shortcut.Execute == nil { + t.Fatalf("%s Execute must be registered", tc.shortcut.Command) + } + if err := tc.shortcut.Execute(context.Background(), rctx); err != nil { + t.Fatalf("%s Execute: %v", tc.shortcut.Command, err) + } + registry.Verify(t) + if !json.Valid(stub.CapturedBody) { + t.Fatalf("request body is not JSON: %s", stub.CapturedBody) + } + out := stdout.String() + for path, want := range tc.want { + if got := gjson.Get(out, path).String(); got != want { + t.Errorf("output %s = %q, want %q: %s", path, got, want, out) + } + } + for path, want := range tc.wantBool { + if got := gjson.Get(out, path).Bool(); got != want { + t.Errorf("output %s = %t, want %t: %s", path, got, want, out) + } + } + for _, forbidden := range []string{"user_open_id", "department_id", "chat_id", "123456789"} { + if strings.Contains(out, forbidden) { + t.Errorf("output exposed typed/internal field %q: %s", forbidden, out) + } + } + }) + } +} + +func TestAppsMemberSettingsExecuteUsesTypedResponses(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + values map[string]string + method string + data map[string]interface{} + want map[string]string + wantBool map[string]bool + }{ + { + name: "get", shortcut: AppsMemberSettingsGet, + values: map[string]string{"app-id": "app_x"}, method: "GET", + data: map[string]interface{}{ + "settings": map[string]interface{}{"external_access": "enabled", "link_share": "tenant-readable", "comment_by": "viewer"}, + }, + want: map[string]string{ + "data.settings.external_access": "enabled", + "data.settings.link_share": "tenant-readable", + "data.settings.comment_by": "viewer", + }, + }, + { + name: "set", shortcut: AppsMemberSettingsSet, + values: map[string]string{"app-id": "app_x", "copy-download-by": "full-access"}, method: "PATCH", + data: map[string]interface{}{ + "settings": map[string]interface{}{"copy_download_by": "full-access"}, + "changes": []interface{}{map[string]interface{}{"field": "copy_download_by", "before": "viewer", "after": "full-access"}}, + "changed": true, + }, + want: map[string]string{ + "data.settings.copy_download_by": "full-access", + "data.changes.0.field": "copy_download_by", + }, + wantBool: map[string]bool{"data.changed": true}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rctx, stdout, registry := newAppsMemberAPIRuntime(t, tc.shortcut, tc.values) + registry.Register(&httpmock.Stub{Method: tc.method, URL: "/open-apis/spark/v1/apps/app_x/member-settings", Body: map[string]interface{}{"code": 0, "msg": "", "data": tc.data}}) + if tc.shortcut.Execute == nil { + t.Fatalf("%s Execute must be registered", tc.shortcut.Command) + } + if err := tc.shortcut.Execute(context.Background(), rctx); err != nil { + t.Fatalf("%s Execute: %v", tc.shortcut.Command, err) + } + registry.Verify(t) + out := stdout.String() + if gjson.Get(out, "data.app").Exists() { + t.Fatalf("%s output unexpectedly contains app: %s", tc.name, out) + } + for path, want := range tc.want { + if got := gjson.Get(out, path).String(); got != want { + t.Errorf("output %s = %q, want %q: %s", path, got, want, out) + } + } + for path, want := range tc.wantBool { + if got := gjson.Get(out, path).Bool(); got != want { + t.Errorf("output %s = %t, want %t: %s", path, got, want, out) + } + } + }) + } +} + +func TestAppsMemberExecuteFailsClosedBeforeOutput(t *testing.T) { + rctx, stdout, registry := newAppsMemberAPIRuntime(t, AppsMemberAdd, map[string]string{ + "app-id": "app_x", "member-type": "openid", "member-id": "ou_requested", "perm": "view", + }) + registry.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/members", + Body: map[string]interface{}{"code": 0, "msg": "", "data": map[string]interface{}{ + "member": map[string]interface{}{"member_type": "user", "user_open_id": "123456789", "role": "view"}, + "changed": true, + }}, + }) + if AppsMemberAdd.Execute == nil { + t.Fatal("member-add Execute must be registered") + } + err := AppsMemberAdd.Execute(context.Background(), rctx) + requireAppsMemberInvalidResponse(t, err) + if stdout.Len() != 0 { + t.Fatalf("malformed member response must not emit data: %s", stdout.String()) + } +} + +func TestAppsMemberSettingsProjectionRejectsUnknownEnumsButAllowsAbsentFields(t *testing.T) { + valid := memberSettingsResponse{} + if err := validateMemberSettingsResponse(valid); err != nil { + t.Fatalf("absent optional settings should be allowed: %v", err) + } + unknown := "internet-editable" + requireAppsMemberInvalidResponse(t, validateMemberSettingsResponse(memberSettingsResponse{LinkShare: &unknown})) +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index b3c3bb0744..7bf0f4525f 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -26,6 +26,12 @@ func Shortcuts() []common.Shortcut { AppsRoleMemberAdd, AppsRoleMemberRemove, AppsRoleMatchList, + AppsMemberList, + AppsMemberAdd, + AppsMemberUpdate, + AppsMemberRemove, + AppsMemberSettingsGet, + AppsMemberSettingsSet, AppsHTMLPublish, AppsInit, AppsReleaseCreate, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 226e9e324e..4642530f8f 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -23,11 +23,50 @@ import ( // - 3 cache(get/delete/clear) // - 3 plugin(install/uninstall/list) // - 6 automation(list/get/create/update/enable/disable) -// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 82。 -func TestAppsShortcuts_Returns82(t *testing.T) { +// - 9 role(role CRUD + role-member list/add/remove + role-match-list) +// - 6 creative app member/permission settings = 88。 +func TestAppsShortcuts_Returns88(t *testing.T) { got := Shortcuts() - if len(got) != 82 { - t.Fatalf("Shortcuts() returned %d entries, want 82", len(got)) + if len(got) != 88 { + t.Fatalf("Shortcuts() returned %d entries, want 88", len(got)) + } +} + +func TestAppsShortcuts_IncludesMemberCommandsWithExactSecurityMetadata(t *testing.T) { + want := map[string]struct { + risk string + scope string + }{ + "+member-list": {risk: "read", scope: "spark:app:read"}, + "+member-add": {risk: "high-risk-write", scope: "spark:app:write"}, + "+member-update": {risk: "high-risk-write", scope: "spark:app:write"}, + "+member-remove": {risk: "high-risk-write", scope: "spark:app:write"}, + "+member-settings-get": {risk: "read", scope: "spark:app:read"}, + "+member-settings-set": {risk: "high-risk-write", scope: "spark:app:write"}, + } + + for _, sc := range Shortcuts() { + expected, ok := want[sc.Command] + if !ok { + continue + } + delete(want, sc.Command) + if sc.Hidden { + t.Errorf("%s must be visible", sc.Command) + } + if sc.Risk != expected.risk { + t.Errorf("%s risk = %q, want %q", sc.Command, sc.Risk, expected.risk) + } + if len(sc.Scopes) != 1 || sc.Scopes[0] != expected.scope { + t.Errorf("%s scopes = %#v, want [%q]", sc.Command, sc.Scopes, expected.scope) + } + if len(sc.AuthTypes) != 1 || sc.AuthTypes[0] != "user" { + t.Errorf("%s auth types = %#v, want [user]", sc.Command, sc.AuthTypes) + } + } + + for command := range want { + t.Errorf("Shortcuts() missing %s", command) } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index c6089cf92e..9d16af3a24 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-apps version: 1.0.0 -description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、本地全栈开发、云端生成迭代、创意设计(UI mockup / 可交互原型 / 线框图 / 落地页 / 仪表盘 / 幻灯片 deck / 视觉探索)、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理、应用角色与成员管理、自动化触发器(定时/记录变更/Webhook/飞书审批)。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或要设计 / design / mockup / prototype / wireframe / 做 PPT / deck / 视觉探索,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、应用角色/角色成员、线上日志、接口请求量、错误量、延迟、访问量、环境变量、给妙搭应用配自动化任务/定时触发/审批通过后自动触发时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" +description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、本地全栈开发、云端生成迭代、创意设计(UI mockup / 可交互原型 / 线框图 / 落地页 / 仪表盘 / 幻灯片 deck / 视觉探索)、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理、应用协作者与协作权限设置、应用角色与成员管理、自动化触发器(定时/记录变更/Webhook/飞书审批)。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或要设计 / design / mockup / prototype / wireframe / 做 PPT / deck / 视觉探索,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、应用协作者/开发权限、应用角色/角色成员、线上日志、接口请求量、错误量、延迟、访问量、环境变量、给妙搭应用配自动化任务/定时触发/审批通过后自动触发时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" metadata: requires: bins: ["lark-cli"] @@ -44,6 +44,7 @@ lark-cli auth login --domain apps | 调试应用运行时缓存:查看/删除单个业务 key、清空指定环境缓存 | `+cache-get`/`+cache-delete`/`+cache-clear` | [`lark-apps-cache.md`](references/lark-apps-cache.md) | | **部署/上线应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | 本地开发链路先按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 确认本次改动已 git commit + git push,再用 `+release-create` / `+release-get`;查历史用 `+release-list` | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) | | 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference | +| 管理应用协作者(列出/添加/改权限/移除)或协作权限设置 | `+member-list`, `+member-add`, `+member-update`, `+member-remove`, `+member-settings-get`, `+member-settings-set` | 本文「应用协作者与协作权限设置」 | | 创意模式(html)应用的评论相关操作 | 创意模式应用评论走 lark-drive 文档评论体系,读取 [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) 了解评论能力 | [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) | | 管理 `app_...` 应用内角色、角色成员,或查询用户匹配角色 | `+role-list/get/create/update/delete`, `+role-member-list/add/remove`, `+role-match-list` | [`lark-apps-role.md`](references/lark-apps-role.md) | | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | @@ -60,6 +61,30 @@ lark-cli auth login --domain apps - **设置环境变量**:如果用户只给应用名,仍先 `+list --keyword` 解析 app_id;设置 online 环境且用户已经明确说“确认/直接执行”时,调用 `+env-set --environment online ... --yes`,不要再次要求确认。回复和日志摘要里只提 key / env / app,不回显真实 value;需要传复杂值时优先用 `@file` 或 stdin。 - **删除环境变量**:`+env-delete` 是破坏性操作。除非用户在同一轮已经明确确认删除这个 app/env/key,否则先向用户确认应用、环境、key 和删除后果;确认后再加 `--yes`。不要因为认证失败/重登完成就自动继续删除,必须保留确认门槛。 +## 应用协作者与协作权限设置 + +这组命令管理妙搭应用的开发协作者和协作策略,不等同于 `+access-scope-*` 的运行时访问范围,也不等同于 `+role-*` 的应用内业务角色。所有命令使用 `app_...` 应用 ID 和 `--as user`。不要读取或判断 `app_type` 来预判支持范围,直接调用对应的协作者命令。 + +- `+member-list`、`+member-settings-get` 是只读命令,需要 `spark:app:read`。 +- `+member-add`、`+member-update`、`+member-remove`、`+member-settings-set` 是高风险写命令,需要 `spark:app:write`。先用 `--dry-run` 核对目标、URL 和请求体;dry-run 不需要 `--yes`。用户已确认具体应用、成员/设置及影响,或已按下方「高影响动作:确认与预授权」对整条流程明确预授权时,真实执行加 `--yes`;否则在 dry-run 后停下请求确认。批量移除成员仍执行「禁止预授权判定底线」,不能从泛化的“直接做”推导出 `--yes`。 +- 添加、更新、移除成员时必须显式提供匹配的外部 ID 类型,禁止传内部数字 ID、猜测类型或做隐式转换:用户 `--member-type openid --member-id ou_...`;群组 `--member-type openchat --member-id oc_...`;部门 `--member-type opendepartmentid --member-id od-...`。 +- `+member-list --member-type` 的筛选枚举是响应对象类型 `user` / `department` / `chat`,与写命令的 ID 类型枚举不同。可再用 `--role view|edit|full_access` 筛选。 +- `+member-list` 只消费 `.data.items`、`.data.has_more` 和 `.data.page_token`。把 `page_token` 当作 opaque 值原样传给下一次 `--page-token`,直到 `has_more=false`;不要解析、拼接或缓存复用 token。收到 subtype `invalid_parameters`(OpenAPI code `3340004`;直连服务可能为 `40004`)时,丢弃旧 token 并从第一页重新读取。 +- 成员响应不包含应用详情。需要名称、类型或发布状态时单独调用 `+get --app-id `,不要期待成员分页重复返回 `app`。 +- 收到 subtype `feature_not_available`(OpenAPI code `3340005`;直连服务可能为 `40005`)时,立即停止 CLI 自动化,不切换 `app_type`,也不尝试用 access scope、应用角色或其它成员命令绕过。向用户说明该应用暂不支持通过 lark-cli 设置协作者,并引导其在妙搭后台的权限设置中操作。 + +```bash +# 读取协作者和当前协作策略 +lark-cli apps +member-list --app-id --as user +lark-cli apps +member-settings-get --app-id --as user + +# 写操作先预览精确的 typed-ID 字段;确认后把 --dry-run 换成 --yes +lark-cli apps +member-add --app-id --member-type openid --member-id ou_xxx --perm view --dry-run --as user +lark-cli apps +member-update --app-id --member-type openchat --member-id oc_xxx --perm edit --dry-run --as user +lark-cli apps +member-remove --app-id --member-type opendepartmentid --member-id od-xxx --dry-run --as user +lark-cli apps +member-settings-set --app-id --external-access disabled --copy-download-by full-access --dry-run --as user +``` + ## 选择开发路径(进意图路由前先判这步) 新建必先定 **app_type** 和**开发方式**两件正交的事;修改已有先按「app_id 获取」指认到 app,指认不到就问用户,不擅自 `+create`。开发方式(本地 vs 云端)只看用户对"谁来写代码"的偏好,与应用复杂度、要不要数据库无关。 @@ -93,7 +118,7 @@ lark-cli auth login --domain apps - 实现领域 SDK 时,以实际包导出的类型和应用内领域 reference 记录的入参、响应路径为准;禁止修改 ambient `.d.ts`、补造宽松类型或强制断言,让猜测的 SDK 结构仅在本地"编译通过"。 - typecheck/build 成功不等于合同正确。交付前逐项核对每个 SDK 调用的入参、响应取值路径和策略分支;涉及更新、删除等不同动作时,分别验证各自动作所需的完整状态,不能复用更弱的前置判断。 - 源码任务交付前确认新增页面、Controller、Module 已接入真实 router/bootstrap,并运行项目现有 typecheck/build;只创建未接线文件不算完成。 -- `+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限;应用协作者/开发权限仍需使用妙搭 Web。自动化触发器请用 `+automation-*`(见「意图路由」)。 +- `+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限;应用协作者/开发权限使用 `+member-*` 和 `+member-settings-*`,应用内业务角色使用 `+role-*`。自动化触发器请用 `+automation-*`(见「意图路由」)。 ## app_id 获取 diff --git a/tests/cli_e2e/apps/coverage.md b/tests/cli_e2e/apps/coverage.md index dffa4a7d33..ed32ed1823 100644 --- a/tests/cli_e2e/apps/coverage.md +++ b/tests/cli_e2e/apps/coverage.md @@ -1,11 +1,11 @@ # Apps CLI E2E Coverage ## Metrics -- Denominator: 19 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts) -- Selected command coverage: 100% (19/19) -- API dry-run coverage: 100% (17/17 API-backed commands) +- Denominator: 25 leaf commands in the selected apps E2E coverage set (not all 88 apps shortcuts) +- Selected command coverage: 100% (25/25) +- API dry-run coverage: 100% (23/23 API-backed commands) - Local E2E coverage: 100% (2/2 local-only commands) -- Live coverage: file and role workflows are fixture-gated and skipped by default CI. File upload covers absolute-path upload, metadata readback, and cleanup; role workflows cover role lifecycle and member mutations with cleanup. +- Live coverage: file and role workflows are fixture-gated and skipped by default CI. File upload covers absolute-path upload, metadata readback, and cleanup; role workflows cover role lifecycle and member mutations with cleanup. The six Creative collaborator commands have targeted dry-run coverage; live E2E is blocked until API Meta exposes and publishes their six RPCs. ## Summary - `TestAppsCreateDryRun`: happy path with `--app-type html`, all-fields shape, rejection paths (missing name, missing app-type, invalid app-type, legacy uppercase `HTML`). `--app-type` is a strict lowercase enum (`html`/`full_stack`); the CLI does not normalize case — legacy uppercase compatibility is a server concern. @@ -24,8 +24,11 @@ - `TestAppsRoleManagementLiveWorkflow`: fixture-gated live role/member workflow against the role provided by `LARK_CLI_E2E_APPS_ROLE_ID`. It refuses to run when the selected member already exists, mutates only that member, and removes only that member during cleanup; it never changes a shared role definition or clears unrelated members. - `TestAppsRoleLifecycleLiveWorkflow`: creates a uniquely named transient role, independently reads it back, updates and re-reads it, adds a fixture member, clears all members and proves the role still exists, then deletes it and verifies the target `role_id` is absent. Cleanup is armed before creation and uses only environment-provided test identifiers. - `TestAppsRoleMatchListLiveWorkflow`: separately fixture-gated live `+role-match-list` proof against the same isolated fixture role. It also requires the selected user to be absent at baseline and removes only the user it added. +- `TestAppsMemberDryRun`: request shapes for all six Creative collaborator commands, including typed external ID projection (`openid` -> `user_open_id`, `openchat` -> `chat_id`, `opendepartmentid` -> `department_id`), filters, pagination, and partial settings PATCH semantics. +- `TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot`: all four collaborator/settings writes require explicit confirmation for live execution while remaining directly inspectable with `--dry-run`. +- `TestAppsMemberValidationFailuresAreStructured`: deterministic validation envelopes for mismatched typed IDs, page bounds, invalid settings enums, and empty settings updates. -Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint, so a create-and-cleanup workflow would leak tenant state. File upload and selected role live workflows remain fixture-gated; each uses dedicated fixtures and cleans up the resources it mutates. +Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint, so a create-and-cleanup workflow would leak tenant state. File upload and selected role live workflows remain fixture-gated; each uses dedicated fixtures and cleans up the resources it mutates. Creative collaborator live E2E is not runnable until API Meta exposes and publishes the six member/member-settings RPCs; dry-run coverage is the current executable contract proof. ## Command Table @@ -50,3 +53,9 @@ Blocked: General app create live E2E is intentionally not implemented yet. Apps | ✓ | apps +role-member-add | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes | `POST /member_add`; body `users/departments/chats` open_id arrays | live covered by fixture-gated role workflow for a provided chat member when available, otherwise a user member | | ✓ | apps +role-member-remove | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleManagementLiveWorkflow; apps_role_management_test.go::TestAppsRoleLifecycleLiveWorkflow | `POST /member_remove`; body `users/departments/chats` open_id arrays or `all=true`; high-risk confirmation | explicit removal and `--all` both have fixture-gated live readback coverage | | ✓ | apps +role-match-list | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleMatchListLiveWorkflow | `POST /user_role_list`; body `target_user_id`; no `role_id`; response field is `roles` per the API contract | automated live runs only when `LARK_CLI_E2E_APPS_ROLE_MATCH_READY=1`; it reuses the role provided by `LARK_CLI_E2E_APPS_ROLE_ID` instead of creating a transient role | +| ✓ | apps +member-list | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun | `--app-id`; optional `--role view/edit/full_access`; `--member-type user/department/chat`; page size 1-100 and opaque page token | targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | +| ✓ | apps +member-add | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID via `--member-type openid/openchat/opendepartmentid` + `--member-id`; `--perm`; optional notification | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | +| ✓ | apps +member-update | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID plus required `--perm`; PATCH only the selected collaborator | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | +| ✓ | apps +member-remove | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID; `POST /members/remove`; no raw/internal numeric IDs | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | +| ✓ | apps +member-settings-get | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun | `--app-id`; GET without body | targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | +| ✓ | apps +member-settings-set | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | partial PATCH of external access/invite, link sharing, collaborator management, comments, and copy/download policy | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | diff --git a/tests/cli_e2e/dryrun/apps_member_dryrun_test.go b/tests/cli_e2e/dryrun/apps_member_dryrun_test.go new file mode 100644 index 0000000000..1842e7c553 --- /dev/null +++ b/tests/cli_e2e/dryrun/apps_member_dryrun_test.go @@ -0,0 +1,213 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package dryrun + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/cmd" + _ "github.com/larksuite/cli/extension/credential/env" + "github.com/larksuite/cli/internal/registry/registrytest" + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +const ( + appsMemberHelperEnv = "LARK_CLI_APPS_MEMBER_HELPER" + appsMemberRootEnv = "LARK_CLI_APPS_MEMBER_TEST_ROOT" +) + +// TestAppsMemberCLIHelperProcess re-executes the current test binary as the +// real CLI entry point. This keeps the E2E proof on the code under test without +// requiring a repository-wide standalone `go build`. +func TestAppsMemberCLIHelperProcess(t *testing.T) { + if os.Getenv(appsMemberHelperEnv) != "1" { + return + } + separator := -1 + for index, arg := range os.Args { + if arg == "--" { + separator = index + break + } + } + if separator < 0 { + os.Exit(2) + } + if err := registrytest.Seed(os.Getenv(appsMemberRootEnv)); err != nil { + fmt.Fprintln(os.Stderr, "seed API metadata fixture:", err) + os.Exit(2) + } + os.Args = append([]string{"lark-cli"}, os.Args[separator+1:]...) + os.Exit(cmd.Execute()) +} + +func runAppsMemberCLI(t *testing.T, args ...string) *clie2e.Result { + t.Helper() + executable, err := os.Executable() + require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + testRoot := t.TempDir() + requestArgs := append([]string{"-test.run=^TestAppsMemberCLIHelperProcess$", "--"}, args...) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + BinaryPath: executable, + Args: requestArgs, + DefaultAs: "user", + Env: map[string]string{ + appsMemberHelperEnv: "1", + appsMemberRootEnv: testRoot, + "LARKSUITE_CLI_CONFIG_DIR": filepath.Join(testRoot, "config"), + "LARKSUITE_CLI_APP_ID": "apps_member_dryrun_client", + "LARKSUITE_CLI_APP_SECRET": "apps_member_dryrun_secret", + "LARKSUITE_CLI_USER_ACCESS_TOKEN": "apps_member_dryrun_user_token", + "LARKSUITE_CLI_BRAND": "feishu", + "LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1", + "LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1", + }, + }) + require.NoError(t, err) + return result +} + +func TestAppsMemberDryRun(t *testing.T) { + tests := []struct { + name string + args []string + method string + url string + assert func(*testing.T, string) + }{ + { + name: "list", args: []string{"apps", "+member-list", "--app-id", " app_报告 ", "--member-type", "chat", "--page-size", "25", "--page-token", "opaque", "--dry-run"}, + method: "GET", url: "/open-apis/spark/v1/apps/app_%E6%8A%A5%E5%91%8A/members", + assert: func(t *testing.T, out string) { + require.Equal(t, "chat", clie2e.DryRunGet(out, "api.0.params.member_type").String()) + require.Equal(t, int64(25), clie2e.DryRunGet(out, "api.0.params.page_size").Int()) + require.Equal(t, "opaque", clie2e.DryRunGet(out, "api.0.params.page_token").String()) + }, + }, + { + name: "add", args: []string{"apps", "+member-add", "--app-id", "app_x", "--member-type", "openid", "--member-id", "ou_user", "--perm", "view", "--need-notification=false", "--dry-run"}, + method: "POST", url: "/open-apis/spark/v1/apps/app_x/members", + assert: func(t *testing.T, out string) { + require.Equal(t, "ou_user", clie2e.DryRunGet(out, "api.0.body.user_open_id").String()) + require.False(t, clie2e.DryRunGet(out, "api.0.body.need_notification").Bool()) + require.False(t, clie2e.DryRunGet(out, "api.0.body.department_id").Exists()) + require.False(t, clie2e.DryRunGet(out, "api.0.body.chat_id").Exists()) + }, + }, + { + name: "update", args: []string{"apps", "+member-update", "--app-id", "app_x", "--member-type", "openchat", "--member-id", "oc_chat", "--perm", "edit", "--dry-run"}, + method: "PATCH", url: "/open-apis/spark/v1/apps/app_x/members", + assert: func(t *testing.T, out string) { + require.Equal(t, "oc_chat", clie2e.DryRunGet(out, "api.0.body.chat_id").String()) + require.Equal(t, "edit", clie2e.DryRunGet(out, "api.0.body.role").String()) + require.False(t, clie2e.DryRunGet(out, "api.0.body.user_open_id").Exists()) + }, + }, + { + name: "remove", args: []string{"apps", "+member-remove", "--app-id", "app_x", "--member-type", "opendepartmentid", "--member-id", "od-department", "--dry-run"}, + method: "POST", url: "/open-apis/spark/v1/apps/app_x/members/remove", + assert: func(t *testing.T, out string) { + require.Equal(t, "od-department", clie2e.DryRunGet(out, "api.0.body.department_id").String()) + require.False(t, clie2e.DryRunGet(out, "api.0.body.user_open_id").Exists()) + require.False(t, clie2e.DryRunGet(out, "api.0.body.chat_id").Exists()) + }, + }, + { + name: "settings-get", args: []string{"apps", "+member-settings-get", "--app-id", "app_x", "--dry-run"}, + method: "GET", url: "/open-apis/spark/v1/apps/app_x/member-settings", + assert: func(t *testing.T, out string) { + require.False(t, clie2e.DryRunGet(out, "api.0.body").Exists()) + }, + }, + { + name: "settings-set", args: []string{"apps", "+member-settings-set", "--app-id", "app_x", "--external-access", "disabled", "--copy-download-by", "full-access", "--dry-run"}, + method: "PATCH", url: "/open-apis/spark/v1/apps/app_x/member-settings", + assert: func(t *testing.T, out string) { + require.Equal(t, "disabled", clie2e.DryRunGet(out, "api.0.body.external_access").String()) + require.Equal(t, "full-access", clie2e.DryRunGet(out, "api.0.body.copy_download_by").String()) + require.False(t, clie2e.DryRunGet(out, "api.0.body.link_share").Exists()) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := runAppsMemberCLI(t, tc.args...) + result.AssertExitCode(t, 0) + require.Equal(t, tc.method, clie2e.DryRunGet(result.Stdout, "api.0.method").String(), "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + require.Equal(t, tc.url, clie2e.DryRunGet(result.Stdout, "api.0.url").String(), "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + tc.assert(t, result.Stdout) + }) + } +} + +func TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot(t *testing.T) { + writes := [][]string{ + {"apps", "+member-add", "--app-id", "app_x", "--member-type", "openid", "--member-id", "ou_user", "--perm", "view"}, + {"apps", "+member-update", "--app-id", "app_x", "--member-type", "openchat", "--member-id", "oc_chat", "--perm", "edit"}, + {"apps", "+member-remove", "--app-id", "app_x", "--member-type", "opendepartmentid", "--member-id", "od-department"}, + {"apps", "+member-settings-set", "--app-id", "app_x", "--external-access", "enabled"}, + } + for _, args := range writes { + t.Run(args[1], func(t *testing.T) { + result := runAppsMemberCLI(t, args...) + result.AssertExitCode(t, 10) + require.Equal(t, "confirmation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr) + require.Equal(t, "confirmation_required", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr) + require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "--yes") + + dryArgs := append(append([]string{}, args...), "--dry-run") + dryResult := runAppsMemberCLI(t, dryArgs...) + dryResult.AssertExitCode(t, 0) + require.True(t, gjson.Get(dryResult.Stdout, "dry_run").Bool(), "stdout:\n%s\nstderr:\n%s", dryResult.Stdout, dryResult.Stderr) + }) + } +} + +func TestAppsMemberValidationFailuresAreStructured(t *testing.T) { + tests := []struct { + name string + args []string + param string + }{ + {name: "missing-app-list", args: []string{"apps", "+member-list", "--dry-run"}, param: "--app-id"}, + {name: "missing-app-add", args: []string{"apps", "+member-add", "--member-type", "openid", "--member-id", "ou_user", "--perm", "view", "--dry-run"}, param: "--app-id"}, + {name: "missing-app-update", args: []string{"apps", "+member-update", "--member-type", "openid", "--member-id", "ou_user", "--perm", "edit", "--dry-run"}, param: "--app-id"}, + {name: "missing-app-remove", args: []string{"apps", "+member-remove", "--member-type", "openid", "--member-id", "ou_user", "--dry-run"}, param: "--app-id"}, + {name: "missing-app-settings-get", args: []string{"apps", "+member-settings-get", "--dry-run"}, param: "--app-id"}, + {name: "missing-app-settings-set", args: []string{"apps", "+member-settings-set", "--external-access", "enabled", "--dry-run"}, param: "--app-id"}, + {name: "mismatched-member-id", args: []string{"apps", "+member-add", "--app-id", "app_x", "--member-type", "openid", "--member-id", "123456789", "--perm", "view", "--dry-run"}, param: "--member-id"}, + {name: "page-size", args: []string{"apps", "+member-list", "--app-id", "app_x", "--page-size", "101", "--dry-run"}, param: "--page-size"}, + {name: "setting-enum", args: []string{"apps", "+member-settings-set", "--app-id", "app_x", "--link-share", "internet-editable", "--dry-run"}, param: "--link-share"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := runAppsMemberCLI(t, tc.args...) + result.AssertExitCode(t, 2) + require.Empty(t, result.Stdout, "validation failure must not write stdout") + require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr) + require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr) + require.Equal(t, tc.param, gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr) + hint := gjson.Get(result.Stderr, "error.hint").String() + message := gjson.Get(result.Stderr, "error.message").String() + require.True(t, hint != "" || strings.Contains(message, "allowed:"), "validation error must provide recovery guidance, stderr:\n%s", result.Stderr) + }) + } + + result := runAppsMemberCLI(t, "apps", "+member-settings-set", "--app-id", "app_x", "--dry-run") + result.AssertExitCode(t, 2) + require.Empty(t, result.Stdout, "validation failure must not write stdout") + require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr) + require.True(t, gjson.Get(result.Stderr, "error.params.#").Int() >= 1, "stderr:\n%s", result.Stderr) +} From 936773d8aa6d71f46d114c6b8ae3ee3a3932eb5d Mon Sep 17 00:00:00 2001 From: yxy-bd Date: Wed, 5 Aug 2026 15:14:25 +0800 Subject: [PATCH 2/6] refactor: remove collaborator list pagination --- shortcuts/apps/apps_member.go | 5 --- shortcuts/apps/apps_member_common.go | 42 ++++--------------- shortcuts/apps/apps_member_common_test.go | 22 ++-------- shortcuts/apps/apps_member_response_test.go | 11 ++--- skills/lark-apps/SKILL.md | 2 +- tests/cli_e2e/apps/coverage.md | 2 +- .../cli_e2e/dryrun/apps_member_dryrun_test.go | 7 ++-- 7 files changed, 22 insertions(+), 69 deletions(-) diff --git a/shortcuts/apps/apps_member.go b/shortcuts/apps/apps_member.go index c3ac664e77..ac39adcd4a 100644 --- a/shortcuts/apps/apps_member.go +++ b/shortcuts/apps/apps_member.go @@ -26,8 +26,6 @@ var AppsMemberList = common.Shortcut{ {Name: "app-id", Desc: "Miaoda app ID (app_...)"}, {Name: "role", Desc: "filter permission", Enum: memberRoles}, {Name: "member-type", Desc: "filter collaborator type", Enum: memberListTypes}, - {Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"}, - {Name: "page-token", Desc: "opaque page token returned by the previous page"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if err := validateMemberAppID(rctx); err != nil { @@ -259,9 +257,6 @@ func renderMemberListPretty(w io.Writer, out memberListOutput) { ) } _ = tw.Flush() - if out.HasMore { - fmt.Fprintf(w, "next page token: %s\n", memberDisplayValue(out.PageToken)) - } } func renderMemberMutationPretty(w io.Writer, action string, member memberOutput, changed bool) { diff --git a/shortcuts/apps/apps_member_common.go b/shortcuts/apps/apps_member_common.go index 53aaac8934..c66daeca27 100644 --- a/shortcuts/apps/apps_member_common.go +++ b/shortcuts/apps/apps_member_common.go @@ -15,11 +15,9 @@ import ( ) const ( - memberListPath = apiBasePath + "/apps/%s/members" - memberRemovePath = apiBasePath + "/apps/%s/members/remove" - memberSettingsPath = apiBasePath + "/apps/%s/member-settings" - defaultMemberPageSize = 20 - maxMemberPageSize = 100 + memberListPath = apiBasePath + "/apps/%s/members" + memberRemovePath = apiBasePath + "/apps/%s/members/remove" + memberSettingsPath = apiBasePath + "/apps/%s/member-settings" ) var ( @@ -37,9 +35,6 @@ func normalizeMemberAPIError(err error) error { return err } switch problem.Code { - case 40004, 3340004: - problem.Subtype = errs.SubtypeInvalidParameters - problem.Hint = "discard --page-token and restart from the first page" case 40005, 3340005: problem.Subtype = errs.SubtypeFeatureNotAvailable problem.Message = "Collaborator management is not available for this app via lark-cli." @@ -161,15 +156,11 @@ type memberSettingChangeResponse struct { } type memberListAPIResponse struct { - Items *[]memberAPIRecord `json:"items"` - HasMore bool `json:"has_more"` - PageToken string `json:"page_token,omitempty"` + Items *[]memberAPIRecord `json:"items"` } type memberListOutput struct { - Items []memberOutput `json:"items"` - HasMore bool `json:"has_more"` - PageToken string `json:"page_token,omitempty"` + Items []memberOutput `json:"items"` } type memberAddAPIResponse struct { @@ -259,16 +250,7 @@ func validateMemberAppID(rctx *common.RuntimeContext) error { } func buildMemberListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) { - pageSize := rctx.Int("page-size") - if pageSize == 0 && !rctx.Changed("page-size") { - pageSize = defaultMemberPageSize - } - if pageSize < 1 || pageSize > maxMemberPageSize { - return nil, appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxMemberPageSize). - WithHint("use --page-size between 1 and 100") - } - - params := map[string]interface{}{"page_size": pageSize} + params := make(map[string]interface{}) if role := strings.TrimSpace(rctx.Str("role")); role != "" { if !memberStringAllowed(role, memberRoles) { return nil, appsValidationParamError("--role", "--role must be one of: view, edit, full_access"). @@ -283,9 +265,6 @@ func buildMemberListParams(rctx *common.RuntimeContext) (map[string]interface{}, } params["member_type"] = memberType } - if pageToken := strings.TrimSpace(rctx.Str("page-token")); pageToken != "" { - params["page_token"] = pageToken - } return params, nil } @@ -463,9 +442,6 @@ func projectMemberListData(data map[string]interface{}) (memberListOutput, error if decoded.Items == nil { return memberListOutput{}, memberInvalidResponse("member list response is missing items") } - if decoded.HasMore && strings.TrimSpace(decoded.PageToken) == "" { - return memberListOutput{}, memberInvalidResponse("member list response has_more=true but page_token is missing") - } items := make([]memberOutput, 0, len(*decoded.Items)) for _, raw := range *decoded.Items { item, err := projectMemberRecord(raw) @@ -474,11 +450,7 @@ func projectMemberListData(data map[string]interface{}) (memberListOutput, error } items = append(items, item) } - return memberListOutput{ - Items: items, - HasMore: decoded.HasMore, - PageToken: decoded.PageToken, - }, nil + return memberListOutput{Items: items}, nil } func projectMemberAddData(data map[string]interface{}) (memberAddOutput, error) { diff --git a/shortcuts/apps/apps_member_common_test.go b/shortcuts/apps/apps_member_common_test.go index c3f972f11a..469d1e22ab 100644 --- a/shortcuts/apps/apps_member_common_test.go +++ b/shortcuts/apps/apps_member_common_test.go @@ -110,14 +110,6 @@ func TestAppsMemberAPIErrorNormalization(t *testing.T) { wantMessage string wantHint string }{ - { - name: "internal invalid page token", code: 40004, wantSubtype: errs.SubtypeInvalidParameters, - wantHint: "discard --page-token and restart from the first page", - }, - { - name: "OpenAPI invalid page token", code: 3340004, wantSubtype: errs.SubtypeInvalidParameters, - wantHint: "discard --page-token and restart from the first page", - }, { name: "internal feature not available", code: 40005, wantSubtype: errs.SubtypeFeatureNotAvailable, wantMessage: "Collaborator management is not available for this app via lark-cli.", @@ -239,7 +231,7 @@ func TestAppsMemberPublicCopyIsGeneric(t *testing.T) { func TestAppsMemberListValidationAndParams(t *testing.T) { valid := newAppsMemberRuntime(t, AppsMemberList, map[string]string{ - "app-id": " app_test ", "role": "edit", "member-type": "chat", "page-size": "100", "page-token": " next-token ", + "app-id": " app_test ", "role": "edit", "member-type": "chat", }) if AppsMemberList.Validate == nil { t.Fatal("member-list Validate must be registered") @@ -252,17 +244,11 @@ func TestAppsMemberListValidationAndParams(t *testing.T) { t.Fatalf("build params: %v", err) } want := map[string]interface{}{ - "role": "edit", "member_type": "chat", "page_size": 100, "page_token": "next-token", + "role": "edit", "member_type": "chat", } if !reflect.DeepEqual(params, want) { t.Fatalf("params = %#v, want %#v", params, want) } - - for _, pageSize := range []string{"0", "101"} { - rctx := newAppsMemberRuntime(t, AppsMemberList, map[string]string{"app-id": "app_test", "page-size": pageSize}) - err := AppsMemberList.Validate(context.Background(), rctx) - requireAppsMemberValidationError(t, err, "--page-size") - } } func TestAppsMemberIdentityValidationMapsExactlyOneTypedField(t *testing.T) { @@ -379,12 +365,12 @@ func TestAppsMemberSettingsSetRequiresAtLeastOneExplicitField(t *testing.T) { func TestAppsMemberDryRunRequestsUseExactRoutesAndTypedBodies(t *testing.T) { t.Run("list", func(t *testing.T) { call := appsMemberDryRunCallFor(t, AppsMemberList, map[string]string{ - "app-id": " app_报告 ", "role": "view", "member-type": "user", "page-size": "25", "page-token": "opaque", + "app-id": " app_报告 ", "role": "view", "member-type": "user", }) if call.Method != "GET" || call.URL != "/open-apis/spark/v1/apps/app_%E6%8A%A5%E5%91%8A/members" { t.Fatalf("list request = %s %s", call.Method, call.URL) } - want := map[string]interface{}{"role": "view", "member_type": "user", "page_size": float64(25), "page_token": "opaque"} + want := map[string]interface{}{"role": "view", "member_type": "user"} if !reflect.DeepEqual(call.Params, want) || call.Body != nil { t.Fatalf("list params/body = %#v / %#v, want %#v / nil", call.Params, call.Body, want) } diff --git a/shortcuts/apps/apps_member_response_test.go b/shortcuts/apps/apps_member_response_test.go index ee007b3cee..f38cca5908 100644 --- a/shortcuts/apps/apps_member_response_test.go +++ b/shortcuts/apps/apps_member_response_test.go @@ -140,7 +140,7 @@ func TestAppsMemberProjectionFailsClosedOnMalformedTypedIDs(t *testing.T) { func TestAppsMemberListExecuteUsesTypedProjectionWithoutLeakingRawFields(t *testing.T) { rctx, stdout, registry := newAppsMemberAPIRuntime(t, AppsMemberList, map[string]string{ - "app-id": "app_x", "member-type": "user", "page-size": "20", + "app-id": "app_x", "member-type": "user", }) stub := &httpmock.Stub{ Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/members", @@ -153,8 +153,7 @@ func TestAppsMemberListExecuteUsesTypedProjectionWithoutLeakingRawFields(t *test "meta_token": "sensitive-internal-token", }, }, - "app": map[string]interface{}{"meta_token": "sensitive-internal-token"}, - "has_more": true, "page_token": "opaque-next", + "app": map[string]interface{}{"meta_token": "sensitive-internal-token"}, }, }, } @@ -173,7 +172,6 @@ func TestAppsMemberListExecuteUsesTypedProjectionWithoutLeakingRawFields(t *test for path, want := range map[string]string{ "data.items.0.member_type": "user", "data.items.0.member_id": "ou_public", - "data.page_token": "opaque-next", } { if got := gjson.Get(out, path).String(); got != want { t.Errorf("output %s = %q, want %q: %s", path, got, want, out) @@ -182,6 +180,9 @@ func TestAppsMemberListExecuteUsesTypedProjectionWithoutLeakingRawFields(t *test if gjson.Get(out, "data.app").Exists() { t.Fatalf("member output unexpectedly contains app: %s", out) } + if gjson.Get(out, "data.page_token").Exists() || gjson.Get(out, "data.has_more").Exists() { + t.Fatalf("member output unexpectedly contains pagination: %s", out) + } for _, forbidden := range []string{"collaborator_id", "user_open_id", "department_id", "chat_id", "123456789", "meta_token", "sensitive-internal-token"} { if strings.Contains(out, forbidden) { t.Errorf("output exposed typed/internal field %q: %s", forbidden, out) @@ -193,7 +194,7 @@ func TestAppsMemberListExecuteNeverLeaksMetaTokenAcrossFormats(t *testing.T) { for _, format := range []string{"json", "table", "csv", "ndjson", "pretty"} { t.Run(format, func(t *testing.T) { rctx, stdout, registry := newAppsMemberAPIRuntime(t, AppsMemberList, map[string]string{ - "app-id": "app_x", "page-size": "20", + "app-id": "app_x", }) rctx.Format = format registry.Register(&httpmock.Stub{ diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 9d16af3a24..d6e1d63d6d 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -69,7 +69,7 @@ lark-cli auth login --domain apps - `+member-add`、`+member-update`、`+member-remove`、`+member-settings-set` 是高风险写命令,需要 `spark:app:write`。先用 `--dry-run` 核对目标、URL 和请求体;dry-run 不需要 `--yes`。用户已确认具体应用、成员/设置及影响,或已按下方「高影响动作:确认与预授权」对整条流程明确预授权时,真实执行加 `--yes`;否则在 dry-run 后停下请求确认。批量移除成员仍执行「禁止预授权判定底线」,不能从泛化的“直接做”推导出 `--yes`。 - 添加、更新、移除成员时必须显式提供匹配的外部 ID 类型,禁止传内部数字 ID、猜测类型或做隐式转换:用户 `--member-type openid --member-id ou_...`;群组 `--member-type openchat --member-id oc_...`;部门 `--member-type opendepartmentid --member-id od-...`。 - `+member-list --member-type` 的筛选枚举是响应对象类型 `user` / `department` / `chat`,与写命令的 ID 类型枚举不同。可再用 `--role view|edit|full_access` 筛选。 -- `+member-list` 只消费 `.data.items`、`.data.has_more` 和 `.data.page_token`。把 `page_token` 当作 opaque 值原样传给下一次 `--page-token`,直到 `has_more=false`;不要解析、拼接或缓存复用 token。收到 subtype `invalid_parameters`(OpenAPI code `3340004`;直连服务可能为 `40004`)时,丢弃旧 token 并从第一页重新读取。 +- `+member-list` 一次返回应用的全部直接协作者,不提供分页参数;可用 `--member-type` 和 `--role` 缩小结果范围。 - 成员响应不包含应用详情。需要名称、类型或发布状态时单独调用 `+get --app-id `,不要期待成员分页重复返回 `app`。 - 收到 subtype `feature_not_available`(OpenAPI code `3340005`;直连服务可能为 `40005`)时,立即停止 CLI 自动化,不切换 `app_type`,也不尝试用 access scope、应用角色或其它成员命令绕过。向用户说明该应用暂不支持通过 lark-cli 设置协作者,并引导其在妙搭后台的权限设置中操作。 diff --git a/tests/cli_e2e/apps/coverage.md b/tests/cli_e2e/apps/coverage.md index ed32ed1823..5727612de9 100644 --- a/tests/cli_e2e/apps/coverage.md +++ b/tests/cli_e2e/apps/coverage.md @@ -53,7 +53,7 @@ Blocked: General app create live E2E is intentionally not implemented yet. Apps | ✓ | apps +role-member-add | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes | `POST /member_add`; body `users/departments/chats` open_id arrays | live covered by fixture-gated role workflow for a provided chat member when available, otherwise a user member | | ✓ | apps +role-member-remove | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleManagementLiveWorkflow; apps_role_management_test.go::TestAppsRoleLifecycleLiveWorkflow | `POST /member_remove`; body `users/departments/chats` open_id arrays or `all=true`; high-risk confirmation | explicit removal and `--all` both have fixture-gated live readback coverage | | ✓ | apps +role-match-list | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleMatchListLiveWorkflow | `POST /user_role_list`; body `target_user_id`; no `role_id`; response field is `roles` per the API contract | automated live runs only when `LARK_CLI_E2E_APPS_ROLE_MATCH_READY=1`; it reuses the role provided by `LARK_CLI_E2E_APPS_ROLE_ID` instead of creating a transient role | -| ✓ | apps +member-list | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun | `--app-id`; optional `--role view/edit/full_access`; `--member-type user/department/chat`; page size 1-100 and opaque page token | targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | +| ✓ | apps +member-list | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun | `--app-id`; optional `--role view/edit/full_access`; `--member-type user/department/chat`; returns all direct collaborators without pagination | targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | | ✓ | apps +member-add | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID via `--member-type openid/openchat/opendepartmentid` + `--member-id`; `--perm`; optional notification | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | | ✓ | apps +member-update | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID plus required `--perm`; PATCH only the selected collaborator | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | | ✓ | apps +member-remove | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID; `POST /members/remove`; no raw/internal numeric IDs | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | diff --git a/tests/cli_e2e/dryrun/apps_member_dryrun_test.go b/tests/cli_e2e/dryrun/apps_member_dryrun_test.go index 1842e7c553..86db734b55 100644 --- a/tests/cli_e2e/dryrun/apps_member_dryrun_test.go +++ b/tests/cli_e2e/dryrun/apps_member_dryrun_test.go @@ -87,12 +87,12 @@ func TestAppsMemberDryRun(t *testing.T) { assert func(*testing.T, string) }{ { - name: "list", args: []string{"apps", "+member-list", "--app-id", " app_报告 ", "--member-type", "chat", "--page-size", "25", "--page-token", "opaque", "--dry-run"}, + name: "list", args: []string{"apps", "+member-list", "--app-id", " app_报告 ", "--member-type", "chat", "--dry-run"}, method: "GET", url: "/open-apis/spark/v1/apps/app_%E6%8A%A5%E5%91%8A/members", assert: func(t *testing.T, out string) { require.Equal(t, "chat", clie2e.DryRunGet(out, "api.0.params.member_type").String()) - require.Equal(t, int64(25), clie2e.DryRunGet(out, "api.0.params.page_size").Int()) - require.Equal(t, "opaque", clie2e.DryRunGet(out, "api.0.params.page_token").String()) + require.False(t, clie2e.DryRunGet(out, "api.0.params.page_size").Exists()) + require.False(t, clie2e.DryRunGet(out, "api.0.params.page_token").Exists()) }, }, { @@ -188,7 +188,6 @@ func TestAppsMemberValidationFailuresAreStructured(t *testing.T) { {name: "missing-app-settings-get", args: []string{"apps", "+member-settings-get", "--dry-run"}, param: "--app-id"}, {name: "missing-app-settings-set", args: []string{"apps", "+member-settings-set", "--external-access", "enabled", "--dry-run"}, param: "--app-id"}, {name: "mismatched-member-id", args: []string{"apps", "+member-add", "--app-id", "app_x", "--member-type", "openid", "--member-id", "123456789", "--perm", "view", "--dry-run"}, param: "--member-id"}, - {name: "page-size", args: []string{"apps", "+member-list", "--app-id", "app_x", "--page-size", "101", "--dry-run"}, param: "--page-size"}, {name: "setting-enum", args: []string{"apps", "+member-settings-set", "--app-id", "app_x", "--link-share", "internet-editable", "--dry-run"}, param: "--link-share"}, } for _, tc := range tests { From 1beffbc3c68b40617f6ba5d795c9886f3da32328 Mon Sep 17 00:00:00 2001 From: yxy-bd Date: Wed, 5 Aug 2026 23:22:42 +0800 Subject: [PATCH 3/6] fix: make external invite setting read-only --- shortcuts/apps/apps_member_common.go | 23 ++++++++++--- shortcuts/apps/apps_member_common_test.go | 34 +++++++++++++++++-- skills/lark-apps/SKILL.md | 1 + tests/cli_e2e/apps/coverage.md | 7 ++-- .../cli_e2e/dryrun/apps_member_dryrun_test.go | 10 ++++++ 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/shortcuts/apps/apps_member_common.go b/shortcuts/apps/apps_member_common.go index c66daeca27..f26ad2dba0 100644 --- a/shortcuts/apps/apps_member_common.go +++ b/shortcuts/apps/apps_member_common.go @@ -40,6 +40,11 @@ func normalizeMemberAPIError(err error) error { problem.Message = "Collaborator management is not available for this app via lark-cli." problem.Hint = "Open this app in Miaoda and manage collaborators from its permission settings." problem.Retryable = false + case 40006, 3340006: + problem.Subtype = errs.SubtypeFeatureNotAvailable + problem.Message = "External collaborator invitations cannot be configured independently." + problem.Hint = "Set --external-access instead; external_invite follows that setting." + problem.Retryable = false case 40400, 3340400: problem.Subtype = errs.SubtypeNotFound } @@ -106,6 +111,7 @@ type memberSettingSpec struct { field string description string allowed []string + readOnly bool setRequest func(*memberSettingsUpdateRequest, *string) responseValue func(memberSettingsResponse) *string } @@ -119,7 +125,7 @@ var memberSettingSpecs = []memberSettingSpec{ }, { flag: "external-invite", field: "external_invite", description: "external collaborator invitations", - allowed: []string{"enabled", "disabled"}, + allowed: []string{"enabled", "disabled"}, readOnly: true, setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.ExternalInvite = value }, responseValue: func(settings memberSettingsResponse) *string { return settings.ExternalInvite }, }, @@ -362,6 +368,13 @@ func buildMemberSettingsUpdateRequest(rctx *common.RuntimeContext) (memberSettin if !rctx.Changed(spec.flag) { continue } + if spec.readOnly { + return memberSettingsUpdateRequest{}, errs.NewValidationError( + errs.SubtypeFeatureNotAvailable, + "--%s is read-only because it follows --external-access", spec.flag, + ).WithParam("--" + spec.flag). + WithHint("set --external-access instead; external_invite follows that setting") + } value := strings.TrimSpace(rctx.Str(spec.flag)) if !memberStringAllowed(value, spec.allowed) { return memberSettingsUpdateRequest{}, appsValidationParamError("--"+spec.flag, "invalid value %q for --%s", value, spec.flag). @@ -402,7 +415,6 @@ func validateMemberSettingsSet(rctx *common.RuntimeContext) error { return appsValidationError("at least one collaborator setting must be provided"). WithParams( appsInvalidParam("--external-access", "not provided"), - appsInvalidParam("--external-invite", "not provided"), appsInvalidParam("--link-share", "not provided"), appsInvalidParam("--manage-collaborators-by", "not provided"), appsInvalidParam("--comment-by", "not provided"), @@ -637,9 +649,10 @@ func memberSettingsSetFlags() []common.Flag { flags = append(flags, common.Flag{Name: "app-id", Desc: "Miaoda app ID (app_...)"}) for _, spec := range memberSettingSpecs { flags = append(flags, common.Flag{ - Name: spec.flag, - Desc: spec.description, - Enum: append([]string(nil), spec.allowed...), + Name: spec.flag, + Desc: spec.description, + Hidden: spec.readOnly, + Enum: append([]string(nil), spec.allowed...), }) } return flags diff --git a/shortcuts/apps/apps_member_common_test.go b/shortcuts/apps/apps_member_common_test.go index 469d1e22ab..18f6839b5c 100644 --- a/shortcuts/apps/apps_member_common_test.go +++ b/shortcuts/apps/apps_member_common_test.go @@ -120,6 +120,16 @@ func TestAppsMemberAPIErrorNormalization(t *testing.T) { wantMessage: "Collaborator management is not available for this app via lark-cli.", wantHint: "Open this app in Miaoda and manage collaborators from its permission settings.", }, + { + name: "external invite follows external access", code: 40006, wantSubtype: errs.SubtypeFeatureNotAvailable, + wantMessage: "External collaborator invitations cannot be configured independently.", + wantHint: "Set --external-access instead; external_invite follows that setting.", + }, + { + name: "OpenAPI external invite follows external access", code: 3340006, wantSubtype: errs.SubtypeFeatureNotAvailable, + wantMessage: "External collaborator invitations cannot be configured independently.", + wantHint: "Set --external-access instead; external_invite follows that setting.", + }, {name: "internal app not found", code: 40400, wantSubtype: errs.SubtypeNotFound}, {name: "OpenAPI app not found", code: 3340400, wantSubtype: errs.SubtypeNotFound}, } @@ -158,7 +168,6 @@ func TestAppsMemberFlagsExposeExactEnums(t *testing.T) { {AppsMemberUpdate, "perm", []string{"view", "edit", "full_access"}}, {AppsMemberRemove, "member-type", []string{"openid", "openchat", "opendepartmentid"}}, {AppsMemberSettingsSet, "external-access", []string{"enabled", "disabled"}}, - {AppsMemberSettingsSet, "external-invite", []string{"enabled", "disabled"}}, {AppsMemberSettingsSet, "link-share", []string{"closed", "tenant-readable", "tenant-editable", "anyone-readable"}}, {AppsMemberSettingsSet, "manage-collaborators-by", []string{"anyone", "same-tenant", "full-access"}}, {AppsMemberSettingsSet, "comment-by", []string{"viewer", "editor"}}, @@ -181,6 +190,28 @@ func TestAppsMemberFlagsExposeExactEnums(t *testing.T) { } } +func TestAppsMemberExternalInviteIsHiddenAndReadOnly(t *testing.T) { + var inviteFlag *common.Flag + for index := range AppsMemberSettingsSet.Flags { + if AppsMemberSettingsSet.Flags[index].Name == "external-invite" { + inviteFlag = &AppsMemberSettingsSet.Flags[index] + break + } + } + if inviteFlag == nil || !inviteFlag.Hidden { + t.Fatalf("--external-invite flag = %#v, want hidden compatibility flag", inviteFlag) + } + rctx := newAppsMemberRuntime(t, AppsMemberSettingsSet, map[string]string{ + "app-id": "app_test", "external-invite": "disabled", + }) + err := AppsMemberSettingsSet.Validate(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + var validationErr *errs.ValidationError + if !ok || problem.Subtype != errs.SubtypeFeatureNotAvailable || !errors.As(err, &validationErr) || validationErr.Param != "--external-invite" || problem.Hint == "" { + t.Fatalf("external invite problem = %+v, ok=%t", problem, ok) + } +} + func TestAppsMemberAppIDValidationIsOwnedByShortcut(t *testing.T) { for _, shortcut := range []common.Shortcut{ AppsMemberList, AppsMemberAdd, AppsMemberUpdate, AppsMemberRemove, @@ -347,7 +378,6 @@ func TestAppsMemberSettingsSetRequiresAtLeastOneExplicitField(t *testing.T) { for _, field := range []struct{ name, value string }{ {name: "external-access", value: "enabled"}, - {name: "external-invite", value: "disabled"}, {name: "link-share", value: "tenant-readable"}, {name: "manage-collaborators-by", value: "same-tenant"}, {name: "comment-by", value: "viewer"}, diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index d6e1d63d6d..e81ca3ee53 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -72,6 +72,7 @@ lark-cli auth login --domain apps - `+member-list` 一次返回应用的全部直接协作者,不提供分页参数;可用 `--member-type` 和 `--role` 缩小结果范围。 - 成员响应不包含应用详情。需要名称、类型或发布状态时单独调用 `+get --app-id `,不要期待成员分页重复返回 `app`。 - 收到 subtype `feature_not_available`(OpenAPI code `3340005`;直连服务可能为 `40005`)时,立即停止 CLI 自动化,不切换 `app_type`,也不尝试用 access scope、应用角色或其它成员命令绕过。向用户说明该应用暂不支持通过 lark-cli 设置协作者,并引导其在妙搭后台的权限设置中操作。 +- `external_invite` 只在 `+member-settings-get` 的响应中读取,不能独立设置;它会跟随 `external_access`。需要改变外部协作能力时只设置 `--external-access`。旧调用传入 `--external-invite` 会返回 subtype `feature_not_available`(OpenAPI code `3340006`;直连服务可能为 `40006`),此时按 hint 改用 `--external-access`,不要重试原请求。 ```bash # 读取协作者和当前协作策略 diff --git a/tests/cli_e2e/apps/coverage.md b/tests/cli_e2e/apps/coverage.md index 5727612de9..bc6cfb78ac 100644 --- a/tests/cli_e2e/apps/coverage.md +++ b/tests/cli_e2e/apps/coverage.md @@ -24,9 +24,10 @@ - `TestAppsRoleManagementLiveWorkflow`: fixture-gated live role/member workflow against the role provided by `LARK_CLI_E2E_APPS_ROLE_ID`. It refuses to run when the selected member already exists, mutates only that member, and removes only that member during cleanup; it never changes a shared role definition or clears unrelated members. - `TestAppsRoleLifecycleLiveWorkflow`: creates a uniquely named transient role, independently reads it back, updates and re-reads it, adds a fixture member, clears all members and proves the role still exists, then deletes it and verifies the target `role_id` is absent. Cleanup is armed before creation and uses only environment-provided test identifiers. - `TestAppsRoleMatchListLiveWorkflow`: separately fixture-gated live `+role-match-list` proof against the same isolated fixture role. It also requires the selected user to be absent at baseline and removes only the user it added. -- `TestAppsMemberDryRun`: request shapes for all six Creative collaborator commands, including typed external ID projection (`openid` -> `user_open_id`, `openchat` -> `chat_id`, `opendepartmentid` -> `department_id`), filters, pagination, and partial settings PATCH semantics. +- `TestAppsMemberDryRun`: request shapes for all six Creative collaborator commands, including typed external ID projection (`openid` -> `user_open_id`, `openchat` -> `chat_id`, `opendepartmentid` -> `department_id`), filters, unpaginated member lists, and partial settings PATCH semantics. - `TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot`: all four collaborator/settings writes require explicit confirmation for live execution while remaining directly inspectable with `--dry-run`. -- `TestAppsMemberValidationFailuresAreStructured`: deterministic validation envelopes for mismatched typed IDs, page bounds, invalid settings enums, and empty settings updates. +- `TestAppsMemberValidationFailuresAreStructured`: deterministic validation envelopes for mismatched typed IDs, invalid settings enums, and empty settings updates. +- `TestAppsMemberExternalInviteFailureIsActionable`: `external_invite` remains readable but its hidden compatibility flag returns a typed `feature_not_available` error that redirects callers to `--external-access`. Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint, so a create-and-cleanup workflow would leak tenant state. File upload and selected role live workflows remain fixture-gated; each uses dedicated fixtures and cleans up the resources it mutates. Creative collaborator live E2E is not runnable until API Meta exposes and publishes the six member/member-settings RPCs; dry-run coverage is the current executable contract proof. @@ -58,4 +59,4 @@ Blocked: General app create live E2E is intentionally not implemented yet. Apps | ✓ | apps +member-update | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID plus required `--perm`; PATCH only the selected collaborator | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | | ✓ | apps +member-remove | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID; `POST /members/remove`; no raw/internal numeric IDs | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | | ✓ | apps +member-settings-get | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun | `--app-id`; GET without body | targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | -| ✓ | apps +member-settings-set | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | partial PATCH of external access/invite, link sharing, collaborator management, comments, and copy/download policy | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | +| ✓ | apps +member-settings-set | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberExternalInviteFailureIsActionable | partial PATCH of external access, link sharing, collaborator management, comments, and copy/download policy; `external_invite` is read-only and follows `external_access` | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | diff --git a/tests/cli_e2e/dryrun/apps_member_dryrun_test.go b/tests/cli_e2e/dryrun/apps_member_dryrun_test.go index 86db734b55..e5e003189a 100644 --- a/tests/cli_e2e/dryrun/apps_member_dryrun_test.go +++ b/tests/cli_e2e/dryrun/apps_member_dryrun_test.go @@ -210,3 +210,13 @@ func TestAppsMemberValidationFailuresAreStructured(t *testing.T) { require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr) require.True(t, gjson.Get(result.Stderr, "error.params.#").Int() >= 1, "stderr:\n%s", result.Stderr) } + +func TestAppsMemberExternalInviteFailureIsActionable(t *testing.T) { + result := runAppsMemberCLI(t, "apps", "+member-settings-set", "--app-id", "app_x", "--external-invite", "disabled", "--dry-run") + result.AssertExitCode(t, 2) + require.Empty(t, result.Stdout) + require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr) + require.Equal(t, "feature_not_available", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr) + require.Equal(t, "--external-invite", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr) + require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "--external-access", "stderr:\n%s", result.Stderr) +} From 5d893ef2f3f2454f0a524d117a62c90770e040da Mon Sep 17 00:00:00 2001 From: yxy-bd Date: Thu, 6 Aug 2026 00:30:10 +0800 Subject: [PATCH 4/6] fix: make Miaoda copy setting read-only --- shortcuts/apps/apps_member_common.go | 18 ++++-- shortcuts/apps/apps_member_common_test.go | 56 ++++++++++++------- shortcuts/apps/apps_member_response_test.go | 11 ++-- skills/lark-apps/SKILL.md | 3 +- tests/cli_e2e/apps/coverage.md | 5 +- .../cli_e2e/dryrun/apps_member_dryrun_test.go | 14 ++++- 6 files changed, 73 insertions(+), 34 deletions(-) diff --git a/shortcuts/apps/apps_member_common.go b/shortcuts/apps/apps_member_common.go index f26ad2dba0..9370eebb36 100644 --- a/shortcuts/apps/apps_member_common.go +++ b/shortcuts/apps/apps_member_common.go @@ -45,6 +45,11 @@ func normalizeMemberAPIError(err error) error { problem.Message = "External collaborator invitations cannot be configured independently." problem.Hint = "Set --external-access instead; external_invite follows that setting." problem.Retryable = false + case 40007, 3340007: + problem.Subtype = errs.SubtypeFeatureNotAvailable + problem.Message = "Copy, print, and download permissions are read-only for Miaoda apps." + problem.Hint = "Inspect copy_download_by with +member-settings-get; do not retry this setting through lark-cli." + problem.Retryable = false case 40400, 3340400: problem.Subtype = errs.SubtypeNotFound } @@ -112,6 +117,8 @@ type memberSettingSpec struct { description string allowed []string readOnly bool + readOnlyError string + readOnlyHint string setRequest func(*memberSettingsUpdateRequest, *string) responseValue func(memberSettingsResponse) *string } @@ -126,6 +133,8 @@ var memberSettingSpecs = []memberSettingSpec{ { flag: "external-invite", field: "external_invite", description: "external collaborator invitations", allowed: []string{"enabled", "disabled"}, readOnly: true, + readOnlyError: "--external-invite is read-only because it follows --external-access", + readOnlyHint: "set --external-access instead; external_invite follows that setting", setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.ExternalInvite = value }, responseValue: func(settings memberSettingsResponse) *string { return settings.ExternalInvite }, }, @@ -149,7 +158,9 @@ var memberSettingSpecs = []memberSettingSpec{ }, { flag: "copy-download-by", field: "copy_download_by", description: "who can copy, print, or download", - allowed: []string{"viewer", "editor", "full-access"}, + allowed: []string{"viewer", "editor", "full-access"}, readOnly: true, + readOnlyError: "--copy-download-by is read-only because CCM does not support updating it for Miaoda apps", + readOnlyHint: "inspect copy_download_by with +member-settings-get; do not retry this setting through lark-cli", setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.CopyDownloadBy = value }, responseValue: func(settings memberSettingsResponse) *string { return settings.CopyDownloadBy }, }, @@ -371,9 +382,9 @@ func buildMemberSettingsUpdateRequest(rctx *common.RuntimeContext) (memberSettin if spec.readOnly { return memberSettingsUpdateRequest{}, errs.NewValidationError( errs.SubtypeFeatureNotAvailable, - "--%s is read-only because it follows --external-access", spec.flag, + "%s", spec.readOnlyError, ).WithParam("--" + spec.flag). - WithHint("set --external-access instead; external_invite follows that setting") + WithHint(spec.readOnlyHint) } value := strings.TrimSpace(rctx.Str(spec.flag)) if !memberStringAllowed(value, spec.allowed) { @@ -418,7 +429,6 @@ func validateMemberSettingsSet(rctx *common.RuntimeContext) error { appsInvalidParam("--link-share", "not provided"), appsInvalidParam("--manage-collaborators-by", "not provided"), appsInvalidParam("--comment-by", "not provided"), - appsInvalidParam("--copy-download-by", "not provided"), ). WithHint("pass at least one setting flag; omitted settings remain unchanged") } diff --git a/shortcuts/apps/apps_member_common_test.go b/shortcuts/apps/apps_member_common_test.go index 18f6839b5c..d0e887e873 100644 --- a/shortcuts/apps/apps_member_common_test.go +++ b/shortcuts/apps/apps_member_common_test.go @@ -130,6 +130,16 @@ func TestAppsMemberAPIErrorNormalization(t *testing.T) { wantMessage: "External collaborator invitations cannot be configured independently.", wantHint: "Set --external-access instead; external_invite follows that setting.", }, + { + name: "copy setting unavailable for Miaoda", code: 40007, wantSubtype: errs.SubtypeFeatureNotAvailable, + wantMessage: "Copy, print, and download permissions are read-only for Miaoda apps.", + wantHint: "Inspect copy_download_by with +member-settings-get; do not retry this setting through lark-cli.", + }, + { + name: "OpenAPI copy setting unavailable for Miaoda", code: 3340007, wantSubtype: errs.SubtypeFeatureNotAvailable, + wantMessage: "Copy, print, and download permissions are read-only for Miaoda apps.", + wantHint: "Inspect copy_download_by with +member-settings-get; do not retry this setting through lark-cli.", + }, {name: "internal app not found", code: 40400, wantSubtype: errs.SubtypeNotFound}, {name: "OpenAPI app not found", code: 3340400, wantSubtype: errs.SubtypeNotFound}, } @@ -171,7 +181,6 @@ func TestAppsMemberFlagsExposeExactEnums(t *testing.T) { {AppsMemberSettingsSet, "link-share", []string{"closed", "tenant-readable", "tenant-editable", "anyone-readable"}}, {AppsMemberSettingsSet, "manage-collaborators-by", []string{"anyone", "same-tenant", "full-access"}}, {AppsMemberSettingsSet, "comment-by", []string{"viewer", "editor"}}, - {AppsMemberSettingsSet, "copy-download-by", []string{"viewer", "editor", "full-access"}}, } for _, tc := range tests { @@ -190,25 +199,33 @@ func TestAppsMemberFlagsExposeExactEnums(t *testing.T) { } } -func TestAppsMemberExternalInviteIsHiddenAndReadOnly(t *testing.T) { - var inviteFlag *common.Flag - for index := range AppsMemberSettingsSet.Flags { - if AppsMemberSettingsSet.Flags[index].Name == "external-invite" { - inviteFlag = &AppsMemberSettingsSet.Flags[index] - break - } - } - if inviteFlag == nil || !inviteFlag.Hidden { - t.Fatalf("--external-invite flag = %#v, want hidden compatibility flag", inviteFlag) +func TestAppsMemberReadOnlySettingsAreHiddenAndActionable(t *testing.T) { + tests := []struct{ flag, value string }{ + {flag: "external-invite", value: "disabled"}, + {flag: "copy-download-by", value: "viewer"}, } - rctx := newAppsMemberRuntime(t, AppsMemberSettingsSet, map[string]string{ - "app-id": "app_test", "external-invite": "disabled", - }) - err := AppsMemberSettingsSet.Validate(context.Background(), rctx) - problem, ok := errs.ProblemOf(err) - var validationErr *errs.ValidationError - if !ok || problem.Subtype != errs.SubtypeFeatureNotAvailable || !errors.As(err, &validationErr) || validationErr.Param != "--external-invite" || problem.Hint == "" { - t.Fatalf("external invite problem = %+v, ok=%t", problem, ok) + for _, tc := range tests { + t.Run(tc.flag, func(t *testing.T) { + var found *common.Flag + for index := range AppsMemberSettingsSet.Flags { + if AppsMemberSettingsSet.Flags[index].Name == tc.flag { + found = &AppsMemberSettingsSet.Flags[index] + break + } + } + if found == nil || !found.Hidden { + t.Fatalf("--%s flag = %#v, want hidden AI guardrail", tc.flag, found) + } + rctx := newAppsMemberRuntime(t, AppsMemberSettingsSet, map[string]string{ + "app-id": "app_test", tc.flag: tc.value, + }) + err := AppsMemberSettingsSet.Validate(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + var validationErr *errs.ValidationError + if !ok || problem.Subtype != errs.SubtypeFeatureNotAvailable || !errors.As(err, &validationErr) || validationErr.Param != "--"+tc.flag || problem.Hint == "" { + t.Fatalf("--%s problem = %+v, ok=%t", tc.flag, problem, ok) + } + }) } } @@ -381,7 +398,6 @@ func TestAppsMemberSettingsSetRequiresAtLeastOneExplicitField(t *testing.T) { {name: "link-share", value: "tenant-readable"}, {name: "manage-collaborators-by", value: "same-tenant"}, {name: "comment-by", value: "viewer"}, - {name: "copy-download-by", value: "full-access"}, } { t.Run(field.name, func(t *testing.T) { rctx := newAppsMemberRuntime(t, AppsMemberSettingsSet, map[string]string{"app-id": "app_test", field.name: field.value}) diff --git a/shortcuts/apps/apps_member_response_test.go b/shortcuts/apps/apps_member_response_test.go index f38cca5908..c92f4d5099 100644 --- a/shortcuts/apps/apps_member_response_test.go +++ b/shortcuts/apps/apps_member_response_test.go @@ -323,15 +323,16 @@ func TestAppsMemberSettingsExecuteUsesTypedResponses(t *testing.T) { }, { name: "set", shortcut: AppsMemberSettingsSet, - values: map[string]string{"app-id": "app_x", "copy-download-by": "full-access"}, method: "PATCH", + values: map[string]string{"app-id": "app_x", "comment-by": "viewer"}, method: "PATCH", data: map[string]interface{}{ - "settings": map[string]interface{}{"copy_download_by": "full-access"}, - "changes": []interface{}{map[string]interface{}{"field": "copy_download_by", "before": "viewer", "after": "full-access"}}, + "settings": map[string]interface{}{"comment_by": "viewer", "copy_download_by": "editor"}, + "changes": []interface{}{map[string]interface{}{"field": "comment_by", "before": "editor", "after": "viewer"}}, "changed": true, }, want: map[string]string{ - "data.settings.copy_download_by": "full-access", - "data.changes.0.field": "copy_download_by", + "data.settings.comment_by": "viewer", + "data.settings.copy_download_by": "editor", + "data.changes.0.field": "comment_by", }, wantBool: map[string]bool{"data.changed": true}, }, diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index e81ca3ee53..9adb826f7f 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -73,6 +73,7 @@ lark-cli auth login --domain apps - 成员响应不包含应用详情。需要名称、类型或发布状态时单独调用 `+get --app-id `,不要期待成员分页重复返回 `app`。 - 收到 subtype `feature_not_available`(OpenAPI code `3340005`;直连服务可能为 `40005`)时,立即停止 CLI 自动化,不切换 `app_type`,也不尝试用 access scope、应用角色或其它成员命令绕过。向用户说明该应用暂不支持通过 lark-cli 设置协作者,并引导其在妙搭后台的权限设置中操作。 - `external_invite` 只在 `+member-settings-get` 的响应中读取,不能独立设置;它会跟随 `external_access`。需要改变外部协作能力时只设置 `--external-access`。旧调用传入 `--external-invite` 会返回 subtype `feature_not_available`(OpenAPI code `3340006`;直连服务可能为 `40006`),此时按 hint 改用 `--external-access`,不要重试原请求。 +- `copy_download_by` 也只在 `+member-settings-get` 的响应中读取。CCM 当前明确不支持为妙搭对象写入复制、打印和下载权限;传入 `--copy-download-by` 会返回 subtype `feature_not_available`(OpenAPI code `3340007`;直连服务可能为 `40007`)。保留读取结果,不要重试写入,也不要改用其它权限字段模拟。 ```bash # 读取协作者和当前协作策略 @@ -83,7 +84,7 @@ lark-cli apps +member-settings-get --app-id --as user lark-cli apps +member-add --app-id --member-type openid --member-id ou_xxx --perm view --dry-run --as user lark-cli apps +member-update --app-id --member-type openchat --member-id oc_xxx --perm edit --dry-run --as user lark-cli apps +member-remove --app-id --member-type opendepartmentid --member-id od-xxx --dry-run --as user -lark-cli apps +member-settings-set --app-id --external-access disabled --copy-download-by full-access --dry-run --as user +lark-cli apps +member-settings-set --app-id --external-access disabled --comment-by viewer --dry-run --as user ``` ## 选择开发路径(进意图路由前先判这步) diff --git a/tests/cli_e2e/apps/coverage.md b/tests/cli_e2e/apps/coverage.md index bc6cfb78ac..9a83de0b13 100644 --- a/tests/cli_e2e/apps/coverage.md +++ b/tests/cli_e2e/apps/coverage.md @@ -27,7 +27,8 @@ - `TestAppsMemberDryRun`: request shapes for all six Creative collaborator commands, including typed external ID projection (`openid` -> `user_open_id`, `openchat` -> `chat_id`, `opendepartmentid` -> `department_id`), filters, unpaginated member lists, and partial settings PATCH semantics. - `TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot`: all four collaborator/settings writes require explicit confirmation for live execution while remaining directly inspectable with `--dry-run`. - `TestAppsMemberValidationFailuresAreStructured`: deterministic validation envelopes for mismatched typed IDs, invalid settings enums, and empty settings updates. -- `TestAppsMemberExternalInviteFailureIsActionable`: `external_invite` remains readable but its hidden compatibility flag returns a typed `feature_not_available` error that redirects callers to `--external-access`. +- `TestAppsMemberExternalInviteFailureIsActionable`: `external_invite` remains readable; attempts to set it return a typed `feature_not_available` error that redirects callers to `--external-access`. +- `TestAppsMemberCopyDownloadFailureIsActionable`: `copy_download_by` remains readable; attempts to set it return a typed `feature_not_available` error because CCM rejects copy settings for Miaoda objects. Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint, so a create-and-cleanup workflow would leak tenant state. File upload and selected role live workflows remain fixture-gated; each uses dedicated fixtures and cleans up the resources it mutates. Creative collaborator live E2E is not runnable until API Meta exposes and publishes the six member/member-settings RPCs; dry-run coverage is the current executable contract proof. @@ -59,4 +60,4 @@ Blocked: General app create live E2E is intentionally not implemented yet. Apps | ✓ | apps +member-update | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID plus required `--perm`; PATCH only the selected collaborator | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | | ✓ | apps +member-remove | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID; `POST /members/remove`; no raw/internal numeric IDs | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | | ✓ | apps +member-settings-get | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun | `--app-id`; GET without body | targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | -| ✓ | apps +member-settings-set | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberExternalInviteFailureIsActionable | partial PATCH of external access, link sharing, collaborator management, comments, and copy/download policy; `external_invite` is read-only and follows `external_access` | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | +| ✓ | apps +member-settings-set | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberExternalInviteFailureIsActionable; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberCopyDownloadFailureIsActionable | partial PATCH of external access, link sharing, collaborator management, and comments; `external_invite` follows `external_access`, while `copy_download_by` is read-only because CCM rejects copy settings for Miaoda objects | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | diff --git a/tests/cli_e2e/dryrun/apps_member_dryrun_test.go b/tests/cli_e2e/dryrun/apps_member_dryrun_test.go index e5e003189a..3eca7b4d17 100644 --- a/tests/cli_e2e/dryrun/apps_member_dryrun_test.go +++ b/tests/cli_e2e/dryrun/apps_member_dryrun_test.go @@ -131,11 +131,11 @@ func TestAppsMemberDryRun(t *testing.T) { }, }, { - name: "settings-set", args: []string{"apps", "+member-settings-set", "--app-id", "app_x", "--external-access", "disabled", "--copy-download-by", "full-access", "--dry-run"}, + name: "settings-set", args: []string{"apps", "+member-settings-set", "--app-id", "app_x", "--external-access", "disabled", "--comment-by", "viewer", "--dry-run"}, method: "PATCH", url: "/open-apis/spark/v1/apps/app_x/member-settings", assert: func(t *testing.T, out string) { require.Equal(t, "disabled", clie2e.DryRunGet(out, "api.0.body.external_access").String()) - require.Equal(t, "full-access", clie2e.DryRunGet(out, "api.0.body.copy_download_by").String()) + require.Equal(t, "viewer", clie2e.DryRunGet(out, "api.0.body.comment_by").String()) require.False(t, clie2e.DryRunGet(out, "api.0.body.link_share").Exists()) }, }, @@ -220,3 +220,13 @@ func TestAppsMemberExternalInviteFailureIsActionable(t *testing.T) { require.Equal(t, "--external-invite", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr) require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "--external-access", "stderr:\n%s", result.Stderr) } + +func TestAppsMemberCopyDownloadFailureIsActionable(t *testing.T) { + result := runAppsMemberCLI(t, "apps", "+member-settings-set", "--app-id", "app_x", "--copy-download-by", "viewer", "--dry-run") + result.AssertExitCode(t, 2) + require.Empty(t, result.Stdout) + require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr) + require.Equal(t, "feature_not_available", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr) + require.Equal(t, "--copy-download-by", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr) + require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "+member-settings-get", "stderr:\n%s", result.Stderr) +} From 4908c483abbbcfa9abbc96e27dec14b7ba8cd81a Mon Sep 17 00:00:00 2001 From: yxy-bd Date: Thu, 6 Aug 2026 00:48:54 +0800 Subject: [PATCH 5/6] docs: record BOE collaborator verification --- tests/cli_e2e/apps/coverage.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/cli_e2e/apps/coverage.md b/tests/cli_e2e/apps/coverage.md index 9a83de0b13..cba905fdd5 100644 --- a/tests/cli_e2e/apps/coverage.md +++ b/tests/cli_e2e/apps/coverage.md @@ -5,7 +5,7 @@ - Selected command coverage: 100% (25/25) - API dry-run coverage: 100% (23/23 API-backed commands) - Local E2E coverage: 100% (2/2 local-only commands) -- Live coverage: file and role workflows are fixture-gated and skipped by default CI. File upload covers absolute-path upload, metadata readback, and cleanup; role workflows cover role lifecycle and member mutations with cleanup. The six Creative collaborator commands have targeted dry-run coverage; live E2E is blocked until API Meta exposes and publishes their six RPCs. +- Live coverage: file and role workflows are fixture-gated and skipped by default CI. File upload covers absolute-path upload, metadata readback, and cleanup; role workflows cover role lifecycle and member mutations with cleanup. All six Miaoda collaborator commands have manual Feishu BOE evidence: user/department/chat CRUD was verified with cleanup on 2026-08-04, and unpaginated list plus settings get/set, read-only errors, combined writes, readback, and rollback were reverified on 2026-08-06. ## Summary - `TestAppsCreateDryRun`: happy path with `--app-type html`, all-fields shape, rejection paths (missing name, missing app-type, invalid app-type, legacy uppercase `HTML`). `--app-type` is a strict lowercase enum (`html`/`full_stack`); the CLI does not normalize case — legacy uppercase compatibility is a server concern. @@ -30,7 +30,7 @@ - `TestAppsMemberExternalInviteFailureIsActionable`: `external_invite` remains readable; attempts to set it return a typed `feature_not_available` error that redirects callers to `--external-access`. - `TestAppsMemberCopyDownloadFailureIsActionable`: `copy_download_by` remains readable; attempts to set it return a typed `feature_not_available` error because CCM rejects copy settings for Miaoda objects. -Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint, so a create-and-cleanup workflow would leak tenant state. File upload and selected role live workflows remain fixture-gated; each uses dedicated fixtures and cleans up the resources it mutates. Creative collaborator live E2E is not runnable until API Meta exposes and publishes the six member/member-settings RPCs; dry-run coverage is the current executable contract proof. +Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint, so a create-and-cleanup workflow would leak tenant state. File upload and selected role live workflows remain fixture-gated; each uses dedicated fixtures and cleans up the resources it mutates. Miaoda collaborator live coverage is currently manual rather than default-CI automation because its write cases require isolated user, department, and chat fixtures; the BOE contract and cleanup workflows have been exercised end to end. ## Command Table @@ -55,9 +55,9 @@ Blocked: General app create live E2E is intentionally not implemented yet. Apps | ✓ | apps +role-member-add | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes | `POST /member_add`; body `users/departments/chats` open_id arrays | live covered by fixture-gated role workflow for a provided chat member when available, otherwise a user member | | ✓ | apps +role-member-remove | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleManagementLiveWorkflow; apps_role_management_test.go::TestAppsRoleLifecycleLiveWorkflow | `POST /member_remove`; body `users/departments/chats` open_id arrays or `all=true`; high-risk confirmation | explicit removal and `--all` both have fixture-gated live readback coverage | | ✓ | apps +role-match-list | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleMatchListLiveWorkflow | `POST /user_role_list`; body `target_user_id`; no `role_id`; response field is `roles` per the API contract | automated live runs only when `LARK_CLI_E2E_APPS_ROLE_MATCH_READY=1`; it reuses the role provided by `LARK_CLI_E2E_APPS_ROLE_ID` instead of creating a transient role | -| ✓ | apps +member-list | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun | `--app-id`; optional `--role view/edit/full_access`; `--member-type user/department/chat`; returns all direct collaborators without pagination | targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | -| ✓ | apps +member-add | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID via `--member-type openid/openchat/opendepartmentid` + `--member-id`; `--perm`; optional notification | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | -| ✓ | apps +member-update | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID plus required `--perm`; PATCH only the selected collaborator | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | -| ✓ | apps +member-remove | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID; `POST /members/remove`; no raw/internal numeric IDs | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | -| ✓ | apps +member-settings-get | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun | `--app-id`; GET without body | targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | -| ✓ | apps +member-settings-set | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberExternalInviteFailureIsActionable; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberCopyDownloadFailureIsActionable | partial PATCH of external access, link sharing, collaborator management, and comments; `external_invite` follows `external_access`, while `copy_download_by` is read-only because CCM rejects copy settings for Miaoda objects | high-risk write; targeted dry-run covered; live blocked until API Meta exposes/publishes the RPC | +| ✓ | apps +member-list | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun | `--app-id`; optional `--role view/edit/full_access`; `--member-type user/department/chat`; returns all direct collaborators without pagination | BOE live verified: user/department/chat only, role/type filters, no app member, no pagination | +| ✓ | apps +member-add | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID via `--member-type openid/openchat/opendepartmentid` + `--member-id`; `--perm`; optional notification | BOE live verified for user/department/chat with isolated cleanup; high-risk write remains excluded from default CI | +| ✓ | apps +member-update | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID plus required `--perm`; PATCH only the selected collaborator | BOE live verified for user/department/chat with readback and cleanup; high-risk write remains excluded from default CI | +| ✓ | apps +member-remove | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID; `POST /members/remove`; no raw/internal numeric IDs | BOE live verified for user/department/chat with absence readback; high-risk write remains excluded from default CI | +| ✓ | apps +member-settings-get | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun | `--app-id`; GET without body | BOE live verified with all six returned settings | +| ✓ | apps +member-settings-set | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberExternalInviteFailureIsActionable; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberCopyDownloadFailureIsActionable | partial PATCH of external access, link sharing, collaborator management, and comments; `external_invite` follows `external_access`, while `copy_download_by` is read-only because CCM rejects copy settings for Miaoda objects | BOE live verified for four-field combined write/readback/rollback, no-op, invalid enum, and both read-only errors | From 5e973905963bc21e5abd1a369b287501f597c25b Mon Sep 17 00:00:00 2001 From: yxy-bd Date: Thu, 6 Aug 2026 00:59:24 +0800 Subject: [PATCH 6/6] fix: remove unsupported Miaoda setting flags --- shortcuts/apps/apps_member_common.go | 51 ++++++++----------- shortcuts/apps/apps_member_common_test.go | 32 +++--------- skills/lark-apps/SKILL.md | 4 +- tests/cli_e2e/apps/coverage.md | 5 +- .../cli_e2e/dryrun/apps_member_dryrun_test.go | 33 ++++++------ 5 files changed, 46 insertions(+), 79 deletions(-) diff --git a/shortcuts/apps/apps_member_common.go b/shortcuts/apps/apps_member_common.go index 9370eebb36..b7802937af 100644 --- a/shortcuts/apps/apps_member_common.go +++ b/shortcuts/apps/apps_member_common.go @@ -79,11 +79,9 @@ type memberRemoveRequest struct { type memberSettingsUpdateRequest struct { ExternalAccess *string `json:"external_access,omitempty"` - ExternalInvite *string `json:"external_invite,omitempty"` LinkShare *string `json:"link_share,omitempty"` ManageCollaboratorsBy *string `json:"manage_collaborators_by,omitempty"` CommentBy *string `json:"comment_by,omitempty"` - CopyDownloadBy *string `json:"copy_download_by,omitempty"` } type memberAPIRecord struct { @@ -116,9 +114,7 @@ type memberSettingSpec struct { field string description string allowed []string - readOnly bool - readOnlyError string - readOnlyHint string + writable bool setRequest func(*memberSettingsUpdateRequest, *string) responseValue func(memberSettingsResponse) *string } @@ -126,42 +122,36 @@ type memberSettingSpec struct { var memberSettingSpecs = []memberSettingSpec{ { flag: "external-access", field: "external_access", description: "external sharing", - allowed: []string{"enabled", "disabled"}, + allowed: []string{"enabled", "disabled"}, writable: true, setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.ExternalAccess = value }, responseValue: func(settings memberSettingsResponse) *string { return settings.ExternalAccess }, }, { - flag: "external-invite", field: "external_invite", description: "external collaborator invitations", - allowed: []string{"enabled", "disabled"}, readOnly: true, - readOnlyError: "--external-invite is read-only because it follows --external-access", - readOnlyHint: "set --external-access instead; external_invite follows that setting", - setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.ExternalInvite = value }, + field: "external_invite", + allowed: []string{"enabled", "disabled"}, responseValue: func(settings memberSettingsResponse) *string { return settings.ExternalInvite }, }, { flag: "link-share", field: "link_share", description: "link sharing", - allowed: []string{"closed", "tenant-readable", "tenant-editable", "anyone-readable"}, + allowed: []string{"closed", "tenant-readable", "tenant-editable", "anyone-readable"}, writable: true, setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.LinkShare = value }, responseValue: func(settings memberSettingsResponse) *string { return settings.LinkShare }, }, { flag: "manage-collaborators-by", field: "manage_collaborators_by", description: "who can manage collaborators", - allowed: []string{"anyone", "same-tenant", "full-access"}, + allowed: []string{"anyone", "same-tenant", "full-access"}, writable: true, setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.ManageCollaboratorsBy = value }, responseValue: func(settings memberSettingsResponse) *string { return settings.ManageCollaboratorsBy }, }, { flag: "comment-by", field: "comment_by", description: "who can comment", - allowed: []string{"viewer", "editor"}, + allowed: []string{"viewer", "editor"}, writable: true, setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.CommentBy = value }, responseValue: func(settings memberSettingsResponse) *string { return settings.CommentBy }, }, { - flag: "copy-download-by", field: "copy_download_by", description: "who can copy, print, or download", - allowed: []string{"viewer", "editor", "full-access"}, readOnly: true, - readOnlyError: "--copy-download-by is read-only because CCM does not support updating it for Miaoda apps", - readOnlyHint: "inspect copy_download_by with +member-settings-get; do not retry this setting through lark-cli", - setRequest: func(req *memberSettingsUpdateRequest, value *string) { req.CopyDownloadBy = value }, + field: "copy_download_by", + allowed: []string{"viewer", "editor", "full-access"}, responseValue: func(settings memberSettingsResponse) *string { return settings.CopyDownloadBy }, }, } @@ -376,15 +366,11 @@ func buildMemberRemoveRequest(rctx *common.RuntimeContext) (memberRemoveRequest, func buildMemberSettingsUpdateRequest(rctx *common.RuntimeContext) (memberSettingsUpdateRequest, error) { var req memberSettingsUpdateRequest for _, spec := range memberSettingSpecs { - if !rctx.Changed(spec.flag) { + if !spec.writable { continue } - if spec.readOnly { - return memberSettingsUpdateRequest{}, errs.NewValidationError( - errs.SubtypeFeatureNotAvailable, - "%s", spec.readOnlyError, - ).WithParam("--" + spec.flag). - WithHint(spec.readOnlyHint) + if !rctx.Changed(spec.flag) { + continue } value := strings.TrimSpace(rctx.Str(spec.flag)) if !memberStringAllowed(value, spec.allowed) { @@ -418,6 +404,9 @@ func validateMemberSettingsSet(rctx *common.RuntimeContext) error { return err } for _, spec := range memberSettingSpecs { + if !spec.writable { + continue + } if rctx.Changed(spec.flag) { _, err := buildMemberSettingsUpdateRequest(rctx) return err @@ -658,11 +647,13 @@ func memberSettingsSetFlags() []common.Flag { flags := make([]common.Flag, 0, len(memberSettingSpecs)+1) flags = append(flags, common.Flag{Name: "app-id", Desc: "Miaoda app ID (app_...)"}) for _, spec := range memberSettingSpecs { + if !spec.writable { + continue + } flags = append(flags, common.Flag{ - Name: spec.flag, - Desc: spec.description, - Hidden: spec.readOnly, - Enum: append([]string(nil), spec.allowed...), + Name: spec.flag, + Desc: spec.description, + Enum: append([]string(nil), spec.allowed...), }) } return flags diff --git a/shortcuts/apps/apps_member_common_test.go b/shortcuts/apps/apps_member_common_test.go index d0e887e873..c8bb5ca479 100644 --- a/shortcuts/apps/apps_member_common_test.go +++ b/shortcuts/apps/apps_member_common_test.go @@ -199,33 +199,13 @@ func TestAppsMemberFlagsExposeExactEnums(t *testing.T) { } } -func TestAppsMemberReadOnlySettingsAreHiddenAndActionable(t *testing.T) { - tests := []struct{ flag, value string }{ - {flag: "external-invite", value: "disabled"}, - {flag: "copy-download-by", value: "viewer"}, - } - for _, tc := range tests { - t.Run(tc.flag, func(t *testing.T) { - var found *common.Flag - for index := range AppsMemberSettingsSet.Flags { - if AppsMemberSettingsSet.Flags[index].Name == tc.flag { - found = &AppsMemberSettingsSet.Flags[index] - break - } +func TestAppsMemberUnsupportedWriteSettingsAreNotRegistered(t *testing.T) { + for _, unsupported := range []string{"external-invite", "copy-download-by"} { + for _, flag := range AppsMemberSettingsSet.Flags { + if flag.Name == unsupported { + t.Fatalf("unsupported write flag --%s is registered", unsupported) } - if found == nil || !found.Hidden { - t.Fatalf("--%s flag = %#v, want hidden AI guardrail", tc.flag, found) - } - rctx := newAppsMemberRuntime(t, AppsMemberSettingsSet, map[string]string{ - "app-id": "app_test", tc.flag: tc.value, - }) - err := AppsMemberSettingsSet.Validate(context.Background(), rctx) - problem, ok := errs.ProblemOf(err) - var validationErr *errs.ValidationError - if !ok || problem.Subtype != errs.SubtypeFeatureNotAvailable || !errors.As(err, &validationErr) || validationErr.Param != "--"+tc.flag || problem.Hint == "" { - t.Fatalf("--%s problem = %+v, ok=%t", tc.flag, problem, ok) - } - }) + } } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 9adb826f7f..c9f0d01b18 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -72,8 +72,8 @@ lark-cli auth login --domain apps - `+member-list` 一次返回应用的全部直接协作者,不提供分页参数;可用 `--member-type` 和 `--role` 缩小结果范围。 - 成员响应不包含应用详情。需要名称、类型或发布状态时单独调用 `+get --app-id `,不要期待成员分页重复返回 `app`。 - 收到 subtype `feature_not_available`(OpenAPI code `3340005`;直连服务可能为 `40005`)时,立即停止 CLI 自动化,不切换 `app_type`,也不尝试用 access scope、应用角色或其它成员命令绕过。向用户说明该应用暂不支持通过 lark-cli 设置协作者,并引导其在妙搭后台的权限设置中操作。 -- `external_invite` 只在 `+member-settings-get` 的响应中读取,不能独立设置;它会跟随 `external_access`。需要改变外部协作能力时只设置 `--external-access`。旧调用传入 `--external-invite` 会返回 subtype `feature_not_available`(OpenAPI code `3340006`;直连服务可能为 `40006`),此时按 hint 改用 `--external-access`,不要重试原请求。 -- `copy_download_by` 也只在 `+member-settings-get` 的响应中读取。CCM 当前明确不支持为妙搭对象写入复制、打印和下载权限;传入 `--copy-download-by` 会返回 subtype `feature_not_available`(OpenAPI code `3340007`;直连服务可能为 `40007`)。保留读取结果,不要重试写入,也不要改用其它权限字段模拟。 +- `external_invite` 只在 `+member-settings-get` 的响应中读取,不能独立设置;它会跟随 `external_access`。CLI 不注册 `--external-invite`,需要改变外部协作能力时只设置 `--external-access`。 +- `copy_download_by` 也只在 `+member-settings-get` 的响应中读取。CCM 当前明确不支持为妙搭对象写入复制、打印和下载权限,因此 CLI 不注册 `--copy-download-by`。保留读取结果,不要尝试写入,也不要改用其它权限字段模拟。 ```bash # 读取协作者和当前协作策略 diff --git a/tests/cli_e2e/apps/coverage.md b/tests/cli_e2e/apps/coverage.md index cba905fdd5..7c7854fa55 100644 --- a/tests/cli_e2e/apps/coverage.md +++ b/tests/cli_e2e/apps/coverage.md @@ -27,8 +27,7 @@ - `TestAppsMemberDryRun`: request shapes for all six Creative collaborator commands, including typed external ID projection (`openid` -> `user_open_id`, `openchat` -> `chat_id`, `opendepartmentid` -> `department_id`), filters, unpaginated member lists, and partial settings PATCH semantics. - `TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot`: all four collaborator/settings writes require explicit confirmation for live execution while remaining directly inspectable with `--dry-run`. - `TestAppsMemberValidationFailuresAreStructured`: deterministic validation envelopes for mismatched typed IDs, invalid settings enums, and empty settings updates. -- `TestAppsMemberExternalInviteFailureIsActionable`: `external_invite` remains readable; attempts to set it return a typed `feature_not_available` error that redirects callers to `--external-access`. -- `TestAppsMemberCopyDownloadFailureIsActionable`: `copy_download_by` remains readable; attempts to set it return a typed `feature_not_available` error because CCM rejects copy settings for Miaoda objects. +- `TestAppsMemberUnsupportedWriteFlagsAreNotRegistered`: `external_invite` and `copy_download_by` remain readable, while their unsupported setter flags are absent from the real CLI command and rejected as unknown flags. Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint, so a create-and-cleanup workflow would leak tenant state. File upload and selected role live workflows remain fixture-gated; each uses dedicated fixtures and cleans up the resources it mutates. Miaoda collaborator live coverage is currently manual rather than default-CI automation because its write cases require isolated user, department, and chat fixtures; the BOE contract and cleanup workflows have been exercised end to end. @@ -60,4 +59,4 @@ Blocked: General app create live E2E is intentionally not implemented yet. Apps | ✓ | apps +member-update | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID plus required `--perm`; PATCH only the selected collaborator | BOE live verified for user/department/chat with readback and cleanup; high-risk write remains excluded from default CI | | ✓ | apps +member-remove | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot | typed external ID; `POST /members/remove`; no raw/internal numeric IDs | BOE live verified for user/department/chat with absence readback; high-risk write remains excluded from default CI | | ✓ | apps +member-settings-get | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun | `--app-id`; GET without body | BOE live verified with all six returned settings | -| ✓ | apps +member-settings-set | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberExternalInviteFailureIsActionable; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberCopyDownloadFailureIsActionable | partial PATCH of external access, link sharing, collaborator management, and comments; `external_invite` follows `external_access`, while `copy_download_by` is read-only because CCM rejects copy settings for Miaoda objects | BOE live verified for four-field combined write/readback/rollback, no-op, invalid enum, and both read-only errors | +| ✓ | apps +member-settings-set | shortcut | ../dryrun/apps_member_dryrun_test.go::TestAppsMemberDryRun; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberHighRiskWritesRequireYesButDryRunDoesNot; ../dryrun/apps_member_dryrun_test.go::TestAppsMemberUnsupportedWriteFlagsAreNotRegistered | partial PATCH of external access, link sharing, collaborator management, and comments; unsupported `--external-invite` and `--copy-download-by` flags are not registered | BOE live verified for four-field combined write/readback/rollback, no-op, invalid enum, and server-side read-only errors | diff --git a/tests/cli_e2e/dryrun/apps_member_dryrun_test.go b/tests/cli_e2e/dryrun/apps_member_dryrun_test.go index 3eca7b4d17..45e78360e5 100644 --- a/tests/cli_e2e/dryrun/apps_member_dryrun_test.go +++ b/tests/cli_e2e/dryrun/apps_member_dryrun_test.go @@ -211,22 +211,19 @@ func TestAppsMemberValidationFailuresAreStructured(t *testing.T) { require.True(t, gjson.Get(result.Stderr, "error.params.#").Int() >= 1, "stderr:\n%s", result.Stderr) } -func TestAppsMemberExternalInviteFailureIsActionable(t *testing.T) { - result := runAppsMemberCLI(t, "apps", "+member-settings-set", "--app-id", "app_x", "--external-invite", "disabled", "--dry-run") - result.AssertExitCode(t, 2) - require.Empty(t, result.Stdout) - require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr) - require.Equal(t, "feature_not_available", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr) - require.Equal(t, "--external-invite", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr) - require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "--external-access", "stderr:\n%s", result.Stderr) -} - -func TestAppsMemberCopyDownloadFailureIsActionable(t *testing.T) { - result := runAppsMemberCLI(t, "apps", "+member-settings-set", "--app-id", "app_x", "--copy-download-by", "viewer", "--dry-run") - result.AssertExitCode(t, 2) - require.Empty(t, result.Stdout) - require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr) - require.Equal(t, "feature_not_available", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr) - require.Equal(t, "--copy-download-by", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr) - require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "+member-settings-get", "stderr:\n%s", result.Stderr) +func TestAppsMemberUnsupportedWriteFlagsAreNotRegistered(t *testing.T) { + for _, tc := range []struct{ flag, value string }{ + {flag: "--external-invite", value: "disabled"}, + {flag: "--copy-download-by", value: "viewer"}, + } { + t.Run(tc.flag, func(t *testing.T) { + result := runAppsMemberCLI(t, "apps", "+member-settings-set", "--app-id", "app_x", tc.flag, tc.value, "--dry-run") + result.AssertExitCode(t, 2) + require.Empty(t, result.Stdout) + require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr) + require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr) + require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "unknown flag", "stderr:\n%s", result.Stderr) + require.Equal(t, tc.flag, gjson.Get(result.Stderr, "error.params.0.name").String(), "stderr:\n%s", result.Stderr) + }) + } }