diff --git a/internal/errclass/codemeta_spark.go b/internal/errclass/codemeta_spark.go new file mode 100644 index 0000000000..ff8556fb1f --- /dev/null +++ b/internal/errclass/codemeta_spark.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errclass + +import "github.com/larksuite/cli/errs" + +// sparkCodeMeta holds stable Spark app-role business-code classifications. +// Command-specific recovery guidance belongs in the Apps shortcut layer; the +// numeric code remains the source-specific discriminator on the error envelope. +var sparkCodeMeta = map[int]CodeMeta{ + 3340001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request parameters are invalid + 3344027: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role user count exceeds the service limit + 3344028: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role department count exceeds the service limit + 3344029: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role chat count exceeds the service limit + 3344030: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator required + 3344031: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator or developer required + 3344034: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role ID + 3344035: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // role does not exist + 3344036: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // role ID already exists + 3344037: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // app role count exceeds the service limit + 3344038: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role name + 3344039: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role description + 3344040: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // unsupported member type + 3344041: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid member ID +} + +func init() { mergeCodeMeta(sparkCodeMeta, "spark") } diff --git a/internal/errclass/codemeta_spark_test.go b/internal/errclass/codemeta_spark_test.go new file mode 100644 index 0000000000..ab583aeb5d --- /dev/null +++ b/internal/errclass/codemeta_spark_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errclass + +import ( + "fmt" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestLookupCodeMetaSparkRoleCodes(t *testing.T) { + tests := []struct { + code int + category errs.Category + subtype errs.Subtype + }{ + {3340001, errs.CategoryAPI, errs.SubtypeInvalidParameters}, + {3344027, errs.CategoryAPI, errs.SubtypeQuotaExceeded}, + {3344028, errs.CategoryAPI, errs.SubtypeQuotaExceeded}, + {3344029, errs.CategoryAPI, errs.SubtypeQuotaExceeded}, + {3344030, errs.CategoryAuthorization, errs.SubtypePermissionDenied}, + {3344031, errs.CategoryAuthorization, errs.SubtypePermissionDenied}, + {3344034, errs.CategoryAPI, errs.SubtypeInvalidParameters}, + {3344035, errs.CategoryAPI, errs.SubtypeNotFound}, + {3344036, errs.CategoryAPI, errs.SubtypeAlreadyExists}, + {3344037, errs.CategoryAPI, errs.SubtypeQuotaExceeded}, + {3344038, errs.CategoryAPI, errs.SubtypeInvalidParameters}, + {3344039, errs.CategoryAPI, errs.SubtypeInvalidParameters}, + {3344040, errs.CategoryAPI, errs.SubtypeInvalidParameters}, + {3344041, errs.CategoryAPI, errs.SubtypeInvalidParameters}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%d", tt.code), func(t *testing.T) { + meta, ok := LookupCodeMeta(tt.code) + if !ok { + t.Fatalf("code %d is not registered", tt.code) + } + if meta.Category != tt.category || meta.Subtype != tt.subtype || meta.Retryable { + t.Fatalf("code %d metadata = %+v, want category=%s subtype=%s retryable=false", tt.code, meta, tt.category, tt.subtype) + } + + err := BuildAPIError(map[string]any{ + "code": tt.code, + "msg": "spark role error", + "log_id": "log-spark-role", + }, ClassifyContext{Identity: "user"}) + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("BuildAPIError(%d) = %#v, want typed problem", tt.code, err) + } + if problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Code != tt.code || problem.LogID != "log-spark-role" || problem.Retryable { + t.Fatalf("BuildAPIError(%d) problem = %+v", tt.code, problem) + } + }) + } +} diff --git a/internal/qualitygate/rules/dryrun.go b/internal/qualitygate/rules/dryrun.go index 18a76dfe01..c9cd480edb 100644 --- a/internal/qualitygate/rules/dryrun.go +++ b/internal/qualitygate/rules/dryrun.go @@ -337,7 +337,7 @@ func fakeValueFromPlaceholderName(name string) (string, bool) { case name == "open_id" || hasPlaceholderToken(tokens, "user", "owner", "participant", "approver", "speaker"): return "ou_test123", true case hasPlaceholderToken(tokens, "department", "dept"): - return "od_test123", true + return "od-test123", true case hasPlaceholderToken(tokens, "message"): return "om_test123", true case name == "file_key": diff --git a/internal/qualitygate/rules/dryrun_test.go b/internal/qualitygate/rules/dryrun_test.go index 7a538ffda9..59f63f559b 100644 --- a/internal/qualitygate/rules/dryrun_test.go +++ b/internal/qualitygate/rules/dryrun_test.go @@ -316,6 +316,13 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) { } } +func TestFakeValueFromPlaceholderNameUsesOpenDepartmentPrefix(t *testing.T) { + got, ok := fakeValueFromPlaceholderName("open_department_id") + if !ok || got != "od-test123" { + t.Fatalf("open_department_id placeholder = %q, %v; want od-test123, true", got, ok) + } +} + func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) { cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`) m := manifest.Manifest{Commands: []manifest.Command{{ diff --git a/shortcuts/apps/apps_access_scope_get_test.go b/shortcuts/apps/apps_access_scope_get_test.go index cb80494a4c..9a70de5bd3 100644 --- a/shortcuts/apps/apps_access_scope_get_test.go +++ b/shortcuts/apps/apps_access_scope_get_test.go @@ -20,7 +20,7 @@ func TestAppsAccessScopeGet_Specific(t *testing.T) { "data": map[string]interface{}{ "scope": "Range", "users": []interface{}{"ou_x", "ou_y"}, - "departments": []interface{}{"od_z"}, + "departments": []interface{}{"od-z"}, "chats": []interface{}{"oc_g"}, "apply_config": map[string]interface{}{ "enabled": true, @@ -39,7 +39,7 @@ func TestAppsAccessScopeGet_Specific(t *testing.T) { if !strings.Contains(got, `"scope": "Range"`) { t.Fatalf("scope string not preserved (expect raw \"Range\"): %s", got) } - if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od_z"`) || !strings.Contains(got, `"oc_g"`) { + if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od-z"`) || !strings.Contains(got, `"oc_g"`) { t.Fatalf("users/departments/chats fields missing in envelope: %s", got) } if !strings.Contains(got, `"ou_appr"`) { diff --git a/shortcuts/apps/apps_role.go b/shortcuts/apps/apps_role.go new file mode 100644 index 0000000000..7cb61a0605 --- /dev/null +++ b/shortcuts/apps/apps_role.go @@ -0,0 +1,744 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "strconv" + "strings" + "text/tabwriter" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const maxRoleListScanPages = 1000 + +// AppsRoleList lists app roles. +var AppsRoleList = common.Shortcut{ + Service: appsService, + Command: "+role-list", + Description: "List app roles", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +role-list --app-id ", + "Example: lark-cli apps +role-list --app-id --name Admin --page-size 20", + "When only a role name is known, pass --name for exact matching; call +role-get only after resolving one unique role_id", + "With --name, the CLI scans server pages in batches of 100, then applies --page-size and --page-token to the exact local matches", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true}, + {Name: "name", Desc: "filter roles by exact name"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"}, + {Name: "page-token", Desc: "integer offset returned by the previous role-list response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := validateRoleAppID(rctx); err != nil { + return err + } + _, err := buildRoleListParams(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + // Validate already ran and called buildRoleListParams; error is impossible here. + params, _ := buildRoleListParams(rctx) + params = roleListRequestParams(params, 0) + return common.NewDryRunAPI(). + GET(roleListURL(rctx)). + Desc("List app roles"). + Params(params) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + params, err := buildRoleListParams(rctx) + if err != nil { + return err + } + data, err := executeRoleList(rctx, params) + if err != nil { + return withRoleErrorHint(err, roleOperationList) + } + rctx.OutFormat(data, nil, func(w io.Writer) { + renderRoleListPretty(w, common.GetSlice(data, "items")) + }) + return nil + }, +} + +// AppsRoleGet gets one app role. +var AppsRoleGet = common.Shortcut{ + Service: appsService, + Command: "+role-get", + Description: "Get an app role", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +role-get --app-id --role-id ", + "--role-id is not a human-readable role name; if only a name is known, run +role-list --name and use its unique returned role_id before calling +role-get", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true}, + {Name: "role-id", Desc: roleIDRequiredDesc, Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + return validateRoleID(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET(roleItemURL(rctx)). + Desc("Get app role") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("GET", roleItemURL(rctx), nil, nil) + if err != nil { + return withRoleErrorHint(err, roleOperationGet) + } + role, err := parseRoleDetailResponseData(data, roleID(rctx)) + if err != nil { + return err + } + rctx.OutFormat(data, nil, func(w io.Writer) { + renderRoleGetPretty(w, role) + }) + return nil + }, +} + +// AppsRoleCreate creates an app role. +var AppsRoleCreate = common.Shortcut{ + Service: appsService, + Command: "+role-create", + Description: "Create an app role", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +role-create --app-id --name Admin", + "Example: lark-cli apps +role-create --app-id --name Admin --description 'Can manage orders'", + "Example: lark-cli apps +role-create --app-id --name Admin --role-id role_admin", + "The create response returns data.role; run +role-get with data.role.role_id only when independent verification is required", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true}, + // Keep --name in Validate so the CLI can return the command-specific + // non-invention hint instead of Cobra's generic required-flag error. + {Name: "name", Desc: "role name (required)"}, + {Name: "description", Desc: "role description"}, + {Name: "role-id", Desc: "optional caller-provided role ID ([A-Za-z0-9_-]{1,64})"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := validateRoleAppID(rctx); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("name")) == "" { + return appsValidationParamError("--name", "--name is required"). + WithHint("ask for the intended role name and pass it with --name; do not infer a name from --description") + } + if rctx.Changed("role-id") { + roleID := strings.TrimSpace(rctx.Str("role-id")) + if roleID == "" { + return appsValidationParamError("--role-id", "--role-id must not be empty when provided") + } + return validateOptionalRoleID(roleID) + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST(roleListURL(rctx)). + Desc("Create app role"). + Body(buildRoleCreateBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("POST", roleListURL(rctx), nil, buildRoleCreateBody(rctx)) + if err != nil { + return withRoleErrorHint(err, roleOperationCreate) + } + expectedRoleID := "" + if rctx.Changed("role-id") { + expectedRoleID = roleID(rctx) + } + role, err := parseRoleWriteResponseData(data, expectedRoleID) + if err != nil { + return err + } + rctx.OutFormat(data, nil, func(w io.Writer) { + renderRoleCreatePretty(w, role) + }) + return nil + }, +} + +// AppsRoleUpdate updates an app role. +var AppsRoleUpdate = common.Shortcut{ + Service: appsService, + Command: "+role-update", + Description: "Update an app role", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +role-update --app-id --role-id --name Operator", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true}, + {Name: "role-id", Desc: roleIDRequiredDesc, Required: true}, + {Name: "name", Desc: "new role name"}, + {Name: "description", Desc: "new role description"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := validateRoleID(rctx); err != nil { + return err + } + if rctx.Changed("name") && strings.TrimSpace(rctx.Str("name")) == "" { + return appsValidationParamError("--name", "--name must not be empty when provided"). + WithHint("omit --name if only updating --description") + } + if !rctx.Changed("name") && !rctx.Changed("description") { + reason := "provide at least one of --name or --description" + return appsValidationError("at least one of --name or --description is required"). + WithParams( + appsInvalidParam("--name", reason), + appsInvalidParam("--description", reason), + ). + WithHint("provide --name, --description, or both") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH(roleItemURL(rctx)). + Desc("Update app role"). + Body(buildRoleUpdateBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("PATCH", roleItemURL(rctx), nil, buildRoleUpdateBody(rctx)) + if err != nil { + return withRoleErrorHint(err, roleOperationUpdate) + } + role, err := parseRoleWriteResponseData(data, roleID(rctx)) + if err != nil { + return err + } + rctx.OutFormat(data, nil, func(w io.Writer) { + renderRoleUpdatePretty(w, role) + }) + return nil + }, +} + +// AppsRoleDelete deletes an app role. +var AppsRoleDelete = common.Shortcut{ + Service: appsService, + Command: "+role-delete", + Description: "Delete an app role", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +role-delete --app-id --role-id --yes", + "A delete request alone is not explicit confirmation: first show the exact app, role, current member scope, and irreversible impact; use --yes only after the user confirms that impact", + "When independent verification is required, use +role-list --name and confirm the deleted role_id is absent; a failed +role-get alone does not prove deletion", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true}, + {Name: "role-id", Desc: roleIDRequiredDesc, Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + return validateRoleID(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE(roleItemURL(rctx)). + Desc("Delete app role") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("DELETE", roleItemURL(rctx), nil, nil) + if err != nil { + return withRoleErrorHint(err, roleOperationDelete) + } + deletedRoleID := roleID(rctx) + out, err := normalizeRoleDeleteData(data, deletedRoleID) + if err != nil { + return err + } + rctx.OutFormat(out, nil, func(w io.Writer) { + renderRoleDeletePretty(w, common.GetString(out, "role_id")) + }) + return nil + }, +} + +func roleListURL(rctx *common.RuntimeContext) string { + appID := roleAppID(rctx) + return fmt.Sprintf(roleListPath, validate.EncodePathSegment(appID)) +} + +func roleItemURL(rctx *common.RuntimeContext) string { + appID := roleAppID(rctx) + roleID := roleID(rctx) + return fmt.Sprintf(roleItemPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(roleID)) +} + +func buildRoleListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) { + params, err := buildRolePageParams(rctx) + if err != nil { + return nil, err + } + name := strings.TrimSpace(rctx.Str("name")) + if rctx.Changed("name") && name == "" { + return nil, appsValidationParamError("--name", "--name must not be empty when provided"). + WithHint("omit --name to list all roles, or provide the exact role name to resolve") + } + if name != "" { + params["name"] = name + } + return params, nil +} + +// roleListRequestParams returns the query parameters for one actual backend +// request. Exact-name lookup always starts from server offset zero and scans in +// maximum-sized batches; the caller's limit/offset are applied to local matches. +func roleListRequestParams(params map[string]interface{}, page int) map[string]interface{} { + name, _ := params["name"].(string) + if name == "" { + return params + } + return map[string]interface{}{ + "limit": maxRolePageSize, + "offset": page * maxRolePageSize, + "name": name, + } +} + +func buildRoleCreateBody(rctx *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{ + "name": strings.TrimSpace(rctx.Str("name")), + } + if rctx.Changed("description") { + body["description"] = strings.TrimSpace(rctx.Str("description")) + } + if rctx.Changed("role-id") { + if roleID := strings.TrimSpace(rctx.Str("role-id")); roleID != "" { + body["role_id"] = roleID + } + } + return body +} + +func buildRoleUpdateBody(rctx *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{} + if rctx.Changed("name") { + body["name"] = strings.TrimSpace(rctx.Str("name")) + } + if rctx.Changed("description") { + body["description"] = strings.TrimSpace(rctx.Str("description")) + } + return body +} + +// executeRoleList compensates for Miaoda environments that accept the name +// query parameter but ignore it. A name lookup scans the complete server-side +// result set, applies exact matching locally, and then applies the CLI's +// offset/limit contract to the filtered result. +func executeRoleList(rctx *common.RuntimeContext, params map[string]interface{}) (map[string]interface{}, error) { + name, _ := params["name"].(string) + if name == "" { + data, err := rctx.CallAPITyped("GET", roleListURL(rctx), params, nil) + if err != nil { + return nil, err + } + return normalizeRoleListData(data, params) + } + + requestedLimit := roleIntValue(params["limit"]) + requestedOffset := roleIntValue(params["offset"]) + allMatches := make([]interface{}, 0, requestedLimit) + var firstPage map[string]interface{} + seenRoleIDs := map[string]struct{}{} + seenPageSignatures := map[string]struct{}{} + expectedTotal := -1 + scannedRoleCount := 0 + + for page := 0; ; page++ { + if page >= maxRoleListScanPages { + return nil, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "role list exceeded %d pages while filtering by name", + maxRoleListScanPages, + ).WithHint("retry without --name and paginate using the returned page_token") + } + + scanParams := roleListRequestParams(params, page) + data, err := rctx.CallAPITyped("GET", roleListURL(rctx), scanParams, nil) + if err != nil { + return nil, err + } + if firstPage == nil { + firstPage = data + } + items, hasMore, total, err := parseRoleListPage(data) + if err != nil { + return nil, err + } + if expectedTotal < 0 { + expectedTotal = total + } else if total != expectedTotal { + return nil, roleListProgressError("role list total changed across pages while filtering by name") + } + if scannedRoleCount+len(items) > expectedTotal { + return nil, roleListProgressError("role list returned more roles than its total while filtering by name") + } + scannedRoleCount += len(items) + if hasMore && scannedRoleCount >= expectedTotal { + return nil, roleListProgressError("role list reported more pages after reaching its total while filtering by name") + } + if !hasMore && scannedRoleCount != expectedTotal { + return nil, roleListProgressError("role list ended before returning its declared total while filtering by name") + } + signature, newRoleCount, err := roleListPageProgress(items, seenRoleIDs) + if err != nil { + return nil, err + } + if newRoleCount != len(items) { + return nil, roleListProgressError("role list repeated roles across pages while filtering by name") + } + if _, duplicate := seenPageSignatures[signature]; duplicate { + return nil, roleListProgressError("role list repeated a page while filtering by name") + } + seenPageSignatures[signature] = struct{}{} + if hasMore && (len(items) == 0 || newRoleCount == 0) { + return nil, roleListProgressError("role list reported more pages without returning new roles") + } + for _, item := range items { + role, ok := item.(map[string]interface{}) + if ok && common.GetString(role, "name") == name { + allMatches = append(allMatches, item) + } + } + if !hasMore { + break + } + } + + if firstPage == nil { + firstPage = map[string]interface{}{} + } + return normalizeFilteredRoleListData(firstPage, allMatches, requestedOffset, requestedLimit), nil +} + +func normalizeFilteredRoleListData(data map[string]interface{}, matches []interface{}, offset, limit int) map[string]interface{} { + out := map[string]interface{}{} + for k, v := range data { + out[k] = v + } + + start := offset + if start > len(matches) { + start = len(matches) + } + end := start + limit + if end > len(matches) { + end = len(matches) + } + hasMore := end < len(matches) + items := append([]interface{}(nil), matches[start:end]...) + if items == nil { + items = []interface{}{} + } + out["items"] = items + out["has_more"] = hasMore + out["page_token"] = roleNextPageToken(start, limit, hasMore) + out["total"] = len(matches) + return out +} + +func normalizeRoleListData(data map[string]interface{}, params map[string]interface{}) (map[string]interface{}, error) { + items, hasMore, total, err := parseRoleListPage(data) + if err != nil { + return nil, err + } + out := map[string]interface{}{} + for k, v := range data { + out[k] = v + } + + limit := roleIntValue(params["limit"]) + offset := roleIntValue(params["offset"]) + + out["items"] = items + out["has_more"] = hasMore + out["page_token"] = roleNextPageToken(offset, limit, hasMore) + out["total"] = total + return out, nil +} + +func parseRoleListPage(data map[string]interface{}) ([]interface{}, bool, int, error) { + rawItems, hasItems := data["items"] + items, ok := rawItems.([]interface{}) + if !hasItems || !ok { + return nil, false, 0, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "role list response field items must be an array", + ).WithHint("retry the read; do not treat a missing or malformed role list as empty") + } + if err := validateRoleCollection(items, "role list response field items"); err != nil { + return nil, false, 0, err + } + rawHasMore, hasHasMore := data["has_more"] + hasMore, ok := rawHasMore.(bool) + if !hasHasMore || !ok { + return nil, false, 0, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "role list response field has_more must be a boolean", + ).WithHint("retry the read; pagination is incomplete without a valid has_more value") + } + total, ok := nonNegativeRoleInteger(data["total"]) + if _, exists := data["total"]; !exists || !ok { + return nil, false, 0, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "role list response field total must be a non-negative integer", + ).WithHint("retry the read; do not infer a role count from a missing or malformed total value") + } + return items, hasMore, total, nil +} + +func roleListPageProgress(items []interface{}, seenRoleIDs map[string]struct{}) (string, int, error) { + roleIDs := make([]string, 0, len(items)) + newRoleCount := 0 + for index, item := range items { + _, roleID, err := roleCollectionItem(item, "role list response field items", index) + if err != nil { + return "", 0, err + } + roleIDs = append(roleIDs, roleID) + if _, seen := seenRoleIDs[roleID]; !seen { + seenRoleIDs[roleID] = struct{}{} + newRoleCount++ + } + } + return strings.Join(roleIDs, "\x00"), newRoleCount, nil +} + +func nonNegativeRoleInteger(value interface{}) (int, bool) { + maxInt := uint64(^uint(0) >> 1) + toInt := func(value int64) (int, bool) { + if value < 0 || uint64(value) > maxInt { + return 0, false + } + return int(value), true + } + + switch value := value.(type) { + case int: + if value < 0 { + return 0, false + } + return value, true + case int64: + return toInt(value) + case float64: + maxIntExclusive := math.Ldexp(1, strconv.IntSize-1) + if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || math.Trunc(value) != value || value >= maxIntExclusive { + return 0, false + } + return int(value), true + case json.Number: + parsed, err := value.Int64() + if err != nil { + return 0, false + } + return toInt(parsed) + case string: + if value == "" || strings.IndexFunc(value, func(r rune) bool { + return r < '0' || r > '9' + }) >= 0 { + return 0, false + } + parsed, err := strconv.ParseUint(value, 10, strconv.IntSize) + if err != nil || parsed > maxInt { + return 0, false + } + return int(parsed), true + default: + return 0, false + } +} + +func roleListProgressError(message string) error { + return errs.NewInternalError(errs.SubtypeInvalidResponse, message). + WithHint("retry without --name and paginate manually; do not continue an incomplete exact-name scan") +} + +func normalizeRoleDeleteData(data map[string]interface{}, requestedRoleID string) (map[string]interface{}, error) { + if data == nil { + return nil, invalidRoleDeleteResponse("role delete response data must be an object") + } + if len(data) == 0 { + return map[string]interface{}{ + "role_id": requestedRoleID, + "deleted": true, + }, nil + } + + out := map[string]interface{}{} + for k, v := range data { + out[k] = v + } + rawRoleID, ok := out["role_id"] + if !ok { + return nil, invalidRoleDeleteResponse("role delete response is missing role_id") + } + actualRoleID, stringOK := rawRoleID.(string) + if !stringOK || actualRoleID != requestedRoleID { + return nil, invalidRoleDeleteResponse( + "role delete response role_id does not match requested role_id %q", + requestedRoleID, + ) + } + rawDeleted, ok := out["deleted"] + if !ok { + return nil, invalidRoleDeleteResponse("role delete response is missing deleted") + } + deleted, boolOK := rawDeleted.(bool) + if !boolOK || !deleted { + return nil, invalidRoleDeleteResponse("role delete response did not acknowledge deletion") + } + return out, nil +} + +type roleResponseData struct { + RoleID string + Name string + Description string +} + +func parseRoleDetailResponseData(data map[string]interface{}, expectedRoleID string) (roleResponseData, error) { + return parseRoleResponseData(data, expectedRoleID, true) +} + +func parseRoleWriteResponseData(data map[string]interface{}, expectedRoleID string) (roleResponseData, error) { + return parseRoleResponseData(data, expectedRoleID, false) +} + +func parseRoleResponseData(data map[string]interface{}, expectedRoleID string, requireName bool) (roleResponseData, error) { + if data == nil { + return roleResponseData{}, invalidRoleResponse("role response data must be an object") + } + rawRole, exists := data["role"] + role, ok := rawRole.(map[string]interface{}) + if !exists || !ok || role == nil { + return roleResponseData{}, invalidRoleResponse("role response field role must be an object") + } + rawRoleID, exists := role["role_id"] + roleID, ok := rawRoleID.(string) + roleID = strings.TrimSpace(roleID) + if !exists || !ok || roleID == "" { + return roleResponseData{}, invalidRoleResponse("role response field role.role_id must be a non-empty string") + } + if expectedRoleID != "" && roleID != expectedRoleID { + return roleResponseData{}, invalidRoleResponse( + "role response role_id %q does not match requested role_id %q", + roleID, + expectedRoleID, + ) + } + rawName, nameExists := role["name"] + name, nameOK := rawName.(string) + name = strings.TrimSpace(name) + if requireName && !nameExists { + return roleResponseData{}, invalidRoleResponse("role response field role.name must be a non-empty string") + } + if nameExists && (!nameOK || name == "") { + return roleResponseData{}, invalidRoleResponse("role response field role.name must be a non-empty string") + } + rawDescription, descriptionExists := role["description"] + description, descriptionOK := rawDescription.(string) + if descriptionExists && !descriptionOK { + return roleResponseData{}, invalidRoleResponse("role response field role.description must be a string") + } + return roleResponseData{RoleID: roleID, Name: name, Description: description}, nil +} + +func invalidRoleResponse(message string, args ...interface{}) error { + return errs.NewInternalError(errs.SubtypeInvalidResponse, message, args...). + WithHint("retry the role read; do not treat a missing or malformed role as a successful result") +} + +func invalidRoleDeleteResponse(message string, args ...interface{}) error { + return errs.NewInternalError(errs.SubtypeInvalidResponse, message, args...). + WithHint("do not claim deletion; verify the target role with +role-list --name ") +} + +func roleIntValue(value interface{}) int { + switch v := value.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case json.Number: + i, err := strconv.Atoi(v.String()) + if err == nil { + return i + } + case string: + i, err := strconv.Atoi(strings.TrimSpace(v)) + if err == nil { + return i + } + } + return 0 +} + +func renderRoleCreatePretty(w io.Writer, role roleResponseData) { + fmt.Fprintf(w, "Created role %s\n", roleDisplayValue(role.RoleID)) +} + +func renderRoleGetPretty(w io.Writer, role roleResponseData) { + renderRoleDetailPretty(w, role) +} + +func renderRoleUpdatePretty(w io.Writer, role roleResponseData) { + fmt.Fprintf(w, "Updated role %s\n", roleDisplayValue(role.RoleID)) +} + +func renderRoleDeletePretty(w io.Writer, roleID string) { + fmt.Fprintf(w, "Deleted role %s\n", roleDisplayValue(roleID)) +} + +func renderRoleDetailPretty(w io.Writer, role roleResponseData) { + fmt.Fprintf(w, "role_id: %s\n", roleDisplayValue(role.RoleID)) + fmt.Fprintf(w, "name: %s\n", roleDisplayValue(role.Name)) + fmt.Fprintf(w, "description: %s\n", roleDisplayValue(role.Description)) +} + +func renderRoleListPretty(w io.Writer, items []interface{}) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ROLE ID\tNAME\tDESCRIPTION") + for _, item := range items { + role, ok := item.(map[string]interface{}) + if !ok { + continue + } + fmt.Fprintf(tw, "%s\t%s\t%s\n", + roleDisplayValue(firstNonEmpty(common.GetString(role, "role_id"), common.GetString(role, "id"))), + roleDisplayValue(common.GetString(role, "name")), + roleDisplayValue(common.GetString(role, "description"))) + } + _ = tw.Flush() +} diff --git a/shortcuts/apps/apps_role_common.go b/shortcuts/apps/apps_role_common.go new file mode 100644 index 0000000000..a4e141f462 --- /dev/null +++ b/shortcuts/apps/apps_role_common.go @@ -0,0 +1,490 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "unicode" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/errclass" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + roleListPath = apiBasePath + "/apps/%s/roles" + roleItemPath = apiBasePath + "/apps/%s/roles/%s" + roleMemberListPath = apiBasePath + "/apps/%s/roles/%s/member_list" + roleMemberAddPath = apiBasePath + "/apps/%s/roles/%s/member_add" + roleMemberRemovePath = apiBasePath + "/apps/%s/roles/%s/member_remove" + roleMatchListPath = apiBasePath + "/apps/%s/user_role_list" + defaultRolePageSize = 20 + maxRolePageSize = 100 + maxRoleMembers = 100 + + roleErrInvalidParameters = 3340001 + roleErrUserLimitExceeded = 3344027 + roleErrDepartmentLimitExceeded = 3344028 + roleErrChatLimitExceeded = 3344029 + roleErrAdminRequired = 3344030 + roleErrManagerRequired = 3344031 + roleErrInvalidRoleID = 3344034 + roleErrRoleNotFound = 3344035 + roleErrRoleAlreadyExists = 3344036 + roleErrRoleLimitExceeded = 3344037 + roleErrInvalidRoleName = 3344038 + roleErrInvalidRoleDescription = 3344039 + roleErrUnsupportedMemberType = 3344040 + roleErrInvalidMemberID = 3344041 +) + +var optionalRoleIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +const ( + roleAppHint = "verify --app-id is a Miaoda app_id you can access; list apps with `lark-cli apps +list`" + roleItemHint = "verify --role-id belongs to the app; if you only know a role name, resolve it with `lark-cli apps +role-list --app-id --name ` and use the unique returned role_id" + roleCreateHint = "verify --app-id and role fields; omit --role-id unless you need a caller-provided role ID" + roleMemberHint = "verify --role-id and member IDs; use user open_id, open_department_id, or open_chat_id values" + roleMatchHint = "use --user-id with a user open_id; do not pass role_id or enumerate roles manually" + + roleAppIDRequiredDesc = "Miaoda app ID (required; app_...; use apps +list to find it)" + roleIDRequiredDesc = "role ID (required; [A-Za-z0-9_-]{1,64}; use role-list to find it)" + roleUserIDRequiredDesc = "user open ID (required; ou_...; do not pass a role ID, name, or email)" +) + +type roleErrorOperation uint8 + +const ( + roleOperationList roleErrorOperation = iota + roleOperationGet + roleOperationCreate + roleOperationUpdate + roleOperationDelete + roleOperationMemberList + roleOperationMemberAdd + roleOperationMemberRemove + roleOperationMatchList +) + +type roleMemberGroups struct { + Users []string `json:"users"` + Departments []string `json:"departments"` + Chats []string `json:"chats"` +} + +type roleMemberKind struct { + memberType string + dataKey string + flagName string + prefix string +} + +var roleMemberKinds = []roleMemberKind{ + {memberType: "user", dataKey: "users", flagName: "--users", prefix: "ou_"}, + {memberType: "department", dataKey: "departments", flagName: "--departments", prefix: "od-"}, + {memberType: "chat", dataKey: "chats", flagName: "--chats", prefix: "oc_"}, +} + +func roleAppID(rctx *common.RuntimeContext) string { + return strings.TrimSpace(rctx.Str("app-id")) +} + +func roleID(rctx *common.RuntimeContext) string { + return strings.TrimSpace(rctx.Str("role-id")) +} + +func validateRoleAppID(rctx *common.RuntimeContext) error { + appID := roleAppID(rctx) + if appID == "" { + return appsValidationParamError("--app-id", "--app-id is required"). + WithHint("list your 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 Lark app_id"). + WithHint("pass the app_... value from `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 be a Miaoda app_id starting with app_"). + WithHint("list Miaoda apps with `lark-cli apps +list`, then pass the returned app_id") + } + // app-id must not contain forward slashes (apps are identified by app_xxx IDs). + for _, r := range appID { + if r == '/' || r == '\\' || unicode.IsSpace(r) || unicode.IsControl(r) { + return appsValidationParamError("--app-id", "--app-id must not contain slashes, whitespace, or control characters") + } + } + // Defense-in-depth: block path traversal and URL metacharacters. + if err := validateRolePathSegmentSafe(appID, "--app-id"); err != nil { + return err + } + return nil +} + +func validateRoleID(rctx *common.RuntimeContext) error { + if err := validateRoleAppID(rctx); err != nil { + return err + } + roleID := roleID(rctx) + if roleID == "" { + return appsValidationParamError("--role-id", "--role-id is required"). + WithHint("list roles with `lark-cli apps +role-list --app-id `") + } + return validateExistingRoleIDValue(roleID) +} + +// validateRolePathSegmentSafe rejects path-traversal segments ("..") and URL +// metacharacters (? # %) in values interpolated into a URL path, providing +// defense-in-depth alongside validate.EncodePathSegment. +func validateRolePathSegmentSafe(value, flagName string) error { + for _, seg := range strings.Split(value, "/") { + if seg == ".." { + return appsValidationParamError(flagName, "%s must not contain '..' path traversal", flagName). + WithHint("provide a valid %s without path traversal", flagName) + } + } + if strings.ContainsAny(value, "?#%") { + return appsValidationParamError(flagName, "%s contains invalid URL characters (?, #, %%)", flagName). + WithHint("provide a valid %s without URL metacharacters", flagName) + } + return nil +} + +func validateOptionalRoleID(roleID string) error { + roleID = strings.TrimSpace(roleID) + if roleID == "" { + return nil + } + return validateCreateRoleIDValue(roleID) +} + +func validateCreateRoleIDValue(roleID string) error { + if !optionalRoleIDPattern.MatchString(roleID) { + return appsValidationParamError("--role-id", "--role-id must match [A-Za-z0-9_-]{1,64}"). + WithHint("omit --role-id to let the server generate one") + } + return nil +} + +func validateExistingRoleIDValue(roleID string) error { + if !optionalRoleIDPattern.MatchString(roleID) { + return appsValidationParamError("--role-id", "--role-id must match [A-Za-z0-9_-]{1,64}"). + WithHint("resolve the role with `lark-cli apps +role-list --app-id --name ` and pass its role_id") + } + return nil +} + +func buildRolePageParams(rctx *common.RuntimeContext) (map[string]interface{}, error) { + limit := defaultRolePageSize + if rctx.Changed("page-size") { + limit = rctx.Int("page-size") + } + if limit < 1 || limit > maxRolePageSize { + return nil, appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxRolePageSize). + WithHint("use --page-size between 1 and 100") + } + + offset := 0 + pageToken := strings.TrimSpace(rctx.Str("page-token")) + if pageToken != "" { + parsedOffset, err := strconv.Atoi(pageToken) + if err != nil || parsedOffset < 0 { + return nil, appsValidationParamError("--page-token", "--page-token must be a non-negative integer offset"). + WithHint("reuse page_token from the previous +role-list response") + } + offset = parsedOffset + } + + return map[string]interface{}{ + "limit": limit, + "offset": offset, + }, nil +} + +func roleNextPageToken(offset, limit int, hasMore bool) string { + if !hasMore { + return "" + } + return strconv.Itoa(offset + limit) +} + +func splitRoleMemberCSV(s, flagName string) ([]string, error) { + parts := strings.Split(s, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + value := strings.TrimSpace(part) + if value == "" { + continue + } + // Reject values containing whitespace, control characters, or URL metacharacters + // (member IDs are open_id/open_department_id/open_chat_id which are safe tokens). + if err := validateMemberID(value, flagName); err != nil { + return nil, err + } + values = append(values, value) + } + return values, nil +} + +// validateMemberID rejects values containing characters that are invalid in +// open_id / open_department_id / open_chat_id tokens (whitespace, controls, URL metacharacters). +func validateMemberID(value, flagName string) error { + if err := validateMemberIDPrefix(value, flagName); err != nil { + return err + } + for _, r := range value { + if unicode.IsSpace(r) || unicode.IsControl(r) { + return appsValidationParamError(flagName, "member IDs must not contain whitespace or control characters"). + WithHint("pass comma-separated open_id/open_department_id/open_chat_id values without spaces") + } + if r == '?' || r == '#' || r == '%' || r == '/' || r == '\\' { + return appsValidationParamError(flagName, "member IDs must not contain URL metacharacters (?, #, %, /, \\)"). + WithHint("pass comma-separated open_id/open_department_id/open_chat_id values without URL characters") + } + } + return nil +} + +func validateMemberIDPrefix(value, flagName string) error { + kind, ok := roleMemberKindForFlag(flagName) + if !ok { + return nil + } + if !strings.HasPrefix(value, kind.prefix) || len(value) == len(kind.prefix) { + return appsValidationParamError(flagName, "%s must use %s IDs", flagName, kind.prefix). + WithHint("resolve names or emails to open IDs before calling role member commands") + } + return nil +} + +func roleMemberKindForFlag(flagName string) (roleMemberKind, bool) { + if flagName == "--user-id" { + flagName = "--users" + } + for _, kind := range roleMemberKinds { + if kind.flagName == flagName { + return kind, true + } + } + return roleMemberKind{}, false +} + +func roleMemberKindForType(memberType string) (roleMemberKind, bool) { + for _, kind := range roleMemberKinds { + if kind.memberType == memberType { + return kind, true + } + } + return roleMemberKind{}, false +} + +func roleDisplayValue(value string) string { + value = validate.SanitizeForTerminal(value) + value = strings.NewReplacer("\n", " ", "\t", " ").Replace(value) + return strings.TrimSpace(value) +} + +// withRoleErrorHint refines documented Spark role errors with command-specific +// recovery while preserving the typed error, numeric code, log_id, and any +// server-provided detail. Unknown codes retain the existing Apps fallback. +func withRoleErrorHint(err error, operation roleErrorOperation) error { + if err == nil { + return nil + } + problem, ok := errs.ProblemOf(err) + if !ok { + return err + } + hint := roleErrorHint(problem.Code, operation) + if hint == "" { + return withAppsHint(err, roleFallbackHint(operation)) + } + + existing := strings.TrimSpace(problem.Hint) + canonicalAPIHint := strings.TrimSpace(errclass.APIHint(problem.Subtype)) + switch { + case existing == "", existing == canonicalAPIHint: + problem.Hint = hint + case !strings.Contains(existing, hint): + problem.Hint = existing + "; " + hint + } + return err +} + +func roleFallbackHint(operation roleErrorOperation) string { + switch operation { + case roleOperationList: + return roleAppHint + case roleOperationCreate: + return roleCreateHint + case roleOperationMemberList, roleOperationMemberAdd, roleOperationMemberRemove: + return roleMemberHint + case roleOperationMatchList: + return roleMatchHint + default: + return roleItemHint + } +} + +func roleErrorHint(code int, operation roleErrorOperation) string { + switch code { + case roleErrInvalidParameters: + return roleFallbackHint(operation) + case roleErrAdminRequired: + return "ask an app administrator to perform this operation or grant the calling user app-administrator access" + case roleErrManagerRequired: + return "ask an app administrator or app developer to perform this operation, or grant the calling user app-management access" + case roleErrInvalidRoleID: + if operation == roleOperationCreate { + return "omit --role-id to let the server generate one, or provide a role ID accepted by the role service" + } + case roleErrRoleNotFound: + if operation == roleOperationMatchList { + return "list the app's current roles and retry; role data used for this match may no longer be valid" + } + return roleItemHint + case roleErrRoleAlreadyExists: + if operation == roleOperationCreate { + return "choose a different --role-id or omit --role-id to let the server generate one" + } + case roleErrRoleLimitExceeded: + if operation == roleOperationCreate { + return "delete an unused app role before creating another role" + } + case roleErrInvalidRoleName: + if operation == roleOperationCreate || operation == roleOperationUpdate { + return "adjust --name to a non-empty value accepted by the role service" + } + case roleErrInvalidRoleDescription: + if operation == roleOperationCreate || operation == roleOperationUpdate { + return "adjust --description to a value accepted by the role service" + } + case roleErrUnsupportedMemberType: + if operation == roleOperationMemberList { + return "use --member-type user, department, or chat, or omit --member-type to list all member types" + } + case roleErrInvalidMemberID: + if operation == roleOperationMatchList { + return "resolve the target user to an open_id and retry with --user-id " + } + if operation == roleOperationMemberAdd || operation == roleOperationMemberRemove { + return roleMemberHint + } + case roleErrUserLimitExceeded: + if operation == roleOperationMemberAdd { + return "reduce the users being added with --users, or remove unused user members before retrying" + } + case roleErrDepartmentLimitExceeded: + if operation == roleOperationMemberAdd { + return "reduce the departments being added with --departments, or remove unused department members before retrying" + } + case roleErrChatLimitExceeded: + if operation == roleOperationMemberAdd { + return "reduce the chats being added with --chats, or remove unused chat members before retrying" + } + } + return "" +} + +func roleCollectionItem(item interface{}, collection string, index int) (map[string]interface{}, string, error) { + role, ok := item.(map[string]interface{}) + if !ok { + return nil, "", invalidRoleCollectionResponse("%s item %d must be an object", collection, index) + } + rawRoleID, exists := role["role_id"] + roleID, stringOK := rawRoleID.(string) + roleID = strings.TrimSpace(roleID) + if !exists || !stringOK || roleID == "" { + return nil, "", invalidRoleCollectionResponse("%s item %d must contain a non-empty string role_id", collection, index) + } + rawName, exists := role["name"] + name, stringOK := rawName.(string) + if !exists || !stringOK || strings.TrimSpace(name) == "" { + return nil, "", invalidRoleCollectionResponse("%s item %d must contain a non-empty string name", collection, index) + } + return role, roleID, nil +} + +func validateRoleCollection(items []interface{}, collection string) error { + for index, item := range items { + if _, _, err := roleCollectionItem(item, collection, index); err != nil { + return err + } + } + return nil +} + +func invalidRoleCollectionResponse(format string, args ...interface{}) error { + return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...). + WithHint("retry the read; do not treat missing or malformed role data as an empty or complete result") +} + +func buildRoleMemberGroups(usersCSV, departmentsCSV, chatsCSV string) (roleMemberGroups, error) { + users, err := splitRoleMemberCSV(usersCSV, "--users") + if err != nil { + return roleMemberGroups{}, err + } + departments, err := splitRoleMemberCSV(departmentsCSV, "--departments") + if err != nil { + return roleMemberGroups{}, err + } + chats, err := splitRoleMemberCSV(chatsCSV, "--chats") + if err != nil { + return roleMemberGroups{}, err + } + groups := roleMemberGroups{ + Users: users, + Departments: departments, + Chats: chats, + } + total := len(groups.Users) + len(groups.Departments) + len(groups.Chats) + if total == 0 { + reason := "provide at least one of --users, --departments, or --chats" + return groups, appsValidationError("at least one of --users, --departments, or --chats is required"). + WithParams( + appsInvalidParam("--users", reason), + appsInvalidParam("--departments", reason), + appsInvalidParam("--chats", reason), + ). + WithHint("resolve names to IDs first, then pass --users open_id, --departments open_department_id, or --chats open_chat_id") + } + if total > maxRoleMembers { + return groups, appsValidationError("role members cannot exceed %d", maxRoleMembers). + WithParams(roleMemberLimitParams(groups)...). + WithHint(fmt.Sprintf("reduce the atomic request to at most %d members; the CLI does not split member writes automatically", maxRoleMembers)) + } + return groups, nil +} + +func buildRoleMemberBody(groups roleMemberGroups) map[string]interface{} { + body := map[string]interface{}{} + if len(groups.Users) > 0 { + body["users"] = groups.Users + } + if len(groups.Departments) > 0 { + body["departments"] = groups.Departments + } + if len(groups.Chats) > 0 { + body["chats"] = groups.Chats + } + return body +} + +func roleMemberLimitParams(groups roleMemberGroups) []errs.InvalidParam { + reason := fmt.Sprintf("combined role member count exceeds %d", maxRoleMembers) + params := make([]errs.InvalidParam, 0, len(roleMemberKinds)) + if len(groups.Users) > 0 { + params = append(params, appsInvalidParam("--users", reason)) + } + if len(groups.Departments) > 0 { + params = append(params, appsInvalidParam("--departments", reason)) + } + if len(groups.Chats) > 0 { + params = append(params, appsInvalidParam("--chats", reason)) + } + return params +} diff --git a/shortcuts/apps/apps_role_common_test.go b/shortcuts/apps/apps_role_common_test.go new file mode 100644 index 0000000000..247450faad --- /dev/null +++ b/shortcuts/apps/apps_role_common_test.go @@ -0,0 +1,447 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func newRoleRCtx(t *testing.T, flagDefs map[string]string, flags map[string]string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + cfg := &core.CliConfig{ + AppID: "test-app-" + strings.ToLower(t.Name()), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_test", + } + factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg) + cmd := &cobra.Command{Use: "test-role"} + cmd.SetContext(context.Background()) + for name, typ := range flagDefs { + switch typ { + case "bool": + cmd.Flags().Bool(name, false, "") + case "int": + cmd.Flags().Int(name, 0, "") + case "string_array": + cmd.Flags().StringArray(name, nil, "") + default: + cmd.Flags().String(name, "", "") + } + } + for name, val := range flags { + if err := cmd.Flags().Set(name, val); err != nil { + t.Fatalf("set flag %q = %q: %v", name, val, err) + } + } + rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + return rctx, stdoutBuf, reg +} + +func assertRoleValidationParam(t *testing.T, err error, param string) *errs.Problem { + t.Helper() + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %#v, want typed problem", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("category = %q, want validation", problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want invalid_argument", problem.Subtype) + } + var validation *errs.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("err = %#v, want validation error", err) + } + if validation.Param != param { + t.Fatalf("param = %q, want %s", validation.Param, param) + } + return problem +} + +func assertRoleValidationParams(t *testing.T, err error, params ...string) *errs.Problem { + t.Helper() + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %#v, want typed problem", err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %+v, want validation/invalid_argument", problem) + } + var validation *errs.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("err = %#v, want validation error", err) + } + if validation.Param != "" { + t.Fatalf("param = %q, want omitted for multi-parameter constraint", validation.Param) + } + if len(validation.Params) != len(params) { + t.Fatalf("params = %#v, want %v", validation.Params, params) + } + for index, want := range params { + if validation.Params[index].Name != want || validation.Params[index].Reason == "" { + t.Fatalf("params[%d] = %#v, want name=%q with a reason", index, validation.Params[index], want) + } + } + return problem +} + +func TestBuildRolePageParams_DefaultAndChanged(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, map[string]string{ + "page-size": "int", + "page-token": "string", + }, map[string]string{}) + params, err := buildRolePageParams(rctx) + if err != nil { + t.Fatalf("buildRolePageParams() = %v", err) + } + if params["limit"] != defaultRolePageSize || params["offset"] != 0 { + t.Fatalf("params = %#v, want limit=%d offset=0", params, defaultRolePageSize) + } + + rctx, _, _ = newRoleRCtx(t, map[string]string{ + "page-size": "int", + "page-token": "string", + }, map[string]string{"page-size": "20", "page-token": "40"}) + params, err = buildRolePageParams(rctx) + if err != nil { + t.Fatalf("buildRolePageParams(changed) = %v", err) + } + if params["limit"] != 20 || params["offset"] != 40 { + t.Fatalf("params = %#v, want limit=20 offset=40", params) + } +} + +func TestBuildRolePageParams_RejectsInvalidToken(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, map[string]string{ + "page-size": "int", + "page-token": "string", + }, map[string]string{"page-token": "abc"}) + _, err := buildRolePageParams(rctx) + assertRoleValidationParam(t, err, "--page-token") +} + +func TestBuildRolePageParams_RejectsPageSizeOverMax(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, map[string]string{ + "page-size": "int", + "page-token": "string", + }, map[string]string{"page-size": "101"}) + _, err := buildRolePageParams(rctx) + assertRoleValidationParam(t, err, "--page-size") +} + +func TestValidateOptionalRoleID(t *testing.T) { + for _, good := range []string{"", " role_001 ", "Role-ABC", "abc123", strings.Repeat("a", 64)} { + if err := validateOptionalRoleID(good); err != nil { + t.Fatalf("validateOptionalRoleID(%q) = %v", good, err) + } + } + for _, bad := range []string{"bad/role", "bad role", strings.Repeat("a", 65)} { + err := validateOptionalRoleID(bad) + problem := assertRoleValidationParam(t, err, "--role-id") + if !strings.Contains(problem.Hint, "omit --role-id") { + t.Fatalf("hint = %q, want create-specific omit guidance", problem.Hint) + } + } +} + +func TestRoleFlagHelpersTrim(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, map[string]string{ + "app-id": "string", + "role-id": "string", + }, map[string]string{"app-id": " app_1 ", "role-id": " role_1 "}) + if got := roleAppID(rctx); got != "app_1" { + t.Fatalf("roleAppID() = %q, want app_1", got) + } + if got := roleID(rctx); got != "role_1" { + t.Fatalf("roleID() = %q, want role_1", got) + } + if err := validateRoleID(rctx); err != nil { + t.Fatalf("validateRoleID() = %v, want nil", err) + } +} + +func TestValidateRoleAppIDRejectsEmpty(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, map[string]string{ + "app-id": "string", + }, map[string]string{}) + problem := assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id") + if problem.Message != "--app-id is required" { + t.Fatalf("message = %q, want --app-id is required", problem.Message) + } + if problem.Hint == "" { + t.Fatalf("hint is empty, want recovery guidance") + } +} + +func TestValidateRoleAppIDRejectsPathSegmentUnsafeChars(t *testing.T) { + for _, appID := range []string{"app/bad", `app\bad`, "app bad", "app\u00a0bad", "app\nbad", "app\u0000bad"} { + rctx, _, _ := newRoleRCtx(t, map[string]string{ + "app-id": "string", + }, map[string]string{"app-id": appID}) + assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id") + } +} + +func TestValidateRoleAppIDRejectsLarkCredentialAppID(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, map[string]string{ + "app-id": "string", + }, map[string]string{"app-id": "cli_app"}) + assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id") +} + +func TestValidateRoleAppIDRequiresMiaodaPrefix(t *testing.T) { + for _, appID := range []string{"app", "app_", "miaoda_123", "plain"} { + rctx, _, _ := newRoleRCtx(t, map[string]string{ + "app-id": "string", + }, map[string]string{"app-id": appID}) + problem := assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id") + if !strings.Contains(problem.Message, "starting with app_") { + t.Fatalf("appID=%q message=%q, want app_ guidance", appID, problem.Message) + } + } +} + +func TestValidateRoleIDRejectsInvalidRequiredRoleID(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, map[string]string{ + "app-id": "string", + "role-id": "string", + }, map[string]string{"app-id": "app_x", "role-id": "bad/role"}) + problem := assertRoleValidationParam(t, validateRoleID(rctx), "--role-id") + if strings.Contains(problem.Hint, "omit --role-id") || !strings.Contains(problem.Hint, "+role-list") { + t.Fatalf("hint = %q, want existing-role resolution guidance", problem.Hint) + } +} + +func TestValidateRoleIDRejectsMissingRequiredRoleID(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, map[string]string{ + "app-id": "string", + "role-id": "string", + }, map[string]string{"app-id": "app_x"}) + problem := assertRoleValidationParam(t, validateRoleID(rctx), "--role-id") + if problem.Message != "--role-id is required" { + t.Fatalf("message = %q, want --role-id is required", problem.Message) + } +} + +func TestBuildRoleMemberGroupsAndBody(t *testing.T) { + groups, err := buildRoleMemberGroups(" ou_a,ou_b ", " od-a ", " oc_a ") + if err != nil { + t.Fatalf("buildRoleMemberGroups() = %v", err) + } + if len(groups.Users) != 2 || len(groups.Departments) != 1 || len(groups.Chats) != 1 { + t.Fatalf("groups = %#v", groups) + } + body := buildRoleMemberBody(groups) + assertJSONEquivalent(t, body, map[string]interface{}{ + "users": []interface{}{"ou_a", "ou_b"}, + "departments": []interface{}{"od-a"}, + "chats": []interface{}{"oc_a"}, + }) +} + +func TestBuildRoleMemberGroupsRejectsEmpty(t *testing.T) { + _, err := buildRoleMemberGroups(" , ", "", "") + assertRoleValidationParams(t, err, "--users", "--departments", "--chats") +} + +func TestBuildRoleMemberGroupsRejectsInvalidMemberIDWithSourceParam(t *testing.T) { + tests := []struct { + name string + users string + departments string + chats string + wantParam string + }{ + {name: "users slash", users: "ou/bad", wantParam: "--users"}, + {name: "users email", users: "alice@example.com", wantParam: "--users"}, + {name: "users wrong prefix", users: "user_123", wantParam: "--users"}, + {name: "users prefix only", users: "ou_", wantParam: "--users"}, + {name: "departments wrong prefix", departments: "ou_user", wantParam: "--departments"}, + {name: "departments prefix only", departments: "od-", wantParam: "--departments"}, + {name: "legacy departments prefix", departments: "od_department", wantParam: "--departments"}, + {name: "chats wrong prefix", chats: "od-department", wantParam: "--chats"}, + {name: "chats prefix only", chats: "oc_", wantParam: "--chats"}, + { + name: "departments", + departments: "od-bad value", + wantParam: "--departments", + }, + { + name: "chats", + chats: "oc?bad", + wantParam: "--chats", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := buildRoleMemberGroups(tt.users, tt.departments, tt.chats) + assertRoleValidationParam(t, err, tt.wantParam) + }) + } +} + +func TestBuildRoleMemberGroupsRejectsMoreThanMax(t *testing.T) { + users := make([]string, maxRoleMembers+1) + for i := range users { + users[i] = "ou_test" + } + _, err := buildRoleMemberGroups(strings.Join(users, ","), "", "") + assertRoleValidationParams(t, err, "--users") +} + +func TestBuildRoleMemberGroupsRejectsMoreThanMaxOnlyChats(t *testing.T) { + chats := make([]string, maxRoleMembers+1) + for i := range chats { + chats[i] = "oc_test" + } + _, err := buildRoleMemberGroups("", "", strings.Join(chats, ",")) + problem := assertRoleValidationParams(t, err, "--chats") + if !strings.Contains(problem.Message, "role members cannot exceed 100") { + t.Fatalf("message = %q, want role members limit", problem.Message) + } + if !strings.Contains(problem.Hint, "does not split") || !strings.Contains(problem.Hint, "atomic request") { + t.Fatalf("hint = %q, want no automatic batching guidance", problem.Hint) + } +} + +func TestBuildRoleMemberGroupsOverflowNamesEveryContributingFlag(t *testing.T) { + users := strings.TrimSuffix(strings.Repeat("ou_user,", 60), ",") + chats := strings.TrimSuffix(strings.Repeat("oc_chat,", 41), ",") + _, err := buildRoleMemberGroups(users, "", chats) + assertRoleValidationParams(t, err, "--users", "--chats") +} + +func TestRoleMemberKindsAreCompleteAndStable(t *testing.T) { + want := []roleMemberKind{ + {memberType: "user", dataKey: "users", flagName: "--users", prefix: "ou_"}, + {memberType: "department", dataKey: "departments", flagName: "--departments", prefix: "od-"}, + {memberType: "chat", dataKey: "chats", flagName: "--chats", prefix: "oc_"}, + } + if len(roleMemberKinds) != len(want) { + t.Fatalf("roleMemberKinds = %#v, want %#v", roleMemberKinds, want) + } + for index := range want { + if roleMemberKinds[index] != want[index] { + t.Fatalf("roleMemberKinds[%d] = %#v, want %#v", index, roleMemberKinds[index], want[index]) + } + } +} + +func TestRoleDisplayValueSanitizesAndFlattens(t *testing.T) { + got := roleDisplayValue(" Admin\n\x1b[31mred\x1b[0m\tvalue ") + if got != "Admin red value" { + t.Fatalf("roleDisplayValue() = %q, want flattened safe text", got) + } +} + +func TestRoleNextPageToken(t *testing.T) { + if got := roleNextPageToken(40, 20, true); got != "60" { + t.Fatalf("roleNextPageToken(hasMore) = %q, want 60", got) + } + if got := roleNextPageToken(40, 20, false); got != "" { + t.Fatalf("roleNextPageToken(!hasMore) = %q, want empty", got) + } +} + +func TestWithRoleErrorHintUsesDocumentedRecoveryAndPreservesEnvelope(t *testing.T) { + tests := []struct { + name string + code int + operation roleErrorOperation + wantHint string + forbid string + }{ + {name: "invalid parameters", code: roleErrInvalidParameters, operation: roleOperationList, wantHint: roleAppHint}, + {name: "administrator required", code: roleErrAdminRequired, operation: roleOperationList, wantHint: "app administrator"}, + {name: "administrator or developer required", code: roleErrManagerRequired, operation: roleOperationGet, wantHint: "administrator or app developer"}, + {name: "invalid create role id", code: roleErrInvalidRoleID, operation: roleOperationCreate, wantHint: "omit --role-id"}, + {name: "role missing", code: roleErrRoleNotFound, operation: roleOperationGet, wantHint: "+role-list"}, + {name: "stale match role", code: roleErrRoleNotFound, operation: roleOperationMatchList, wantHint: "may no longer be valid", forbid: "--role-id"}, + {name: "duplicate role id", code: roleErrRoleAlreadyExists, operation: roleOperationCreate, wantHint: "different --role-id"}, + {name: "role limit", code: roleErrRoleLimitExceeded, operation: roleOperationCreate, wantHint: "delete an unused app role"}, + {name: "invalid role name", code: roleErrInvalidRoleName, operation: roleOperationUpdate, wantHint: "adjust --name"}, + {name: "invalid role description", code: roleErrInvalidRoleDescription, operation: roleOperationUpdate, wantHint: "adjust --description"}, + {name: "unsupported member type", code: roleErrUnsupportedMemberType, operation: roleOperationMemberList, wantHint: "user, department, or chat"}, + {name: "invalid member id", code: roleErrInvalidMemberID, operation: roleOperationMemberAdd, wantHint: "member IDs"}, + {name: "invalid match target", code: roleErrInvalidMemberID, operation: roleOperationMatchList, wantHint: "--user-id", forbid: "--role-id"}, + {name: "user quota", code: roleErrUserLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the users"}, + {name: "department quota", code: roleErrDepartmentLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the departments"}, + {name: "chat quota", code: roleErrChatLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the chats"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := errclass.BuildAPIError(map[string]any{ + "code": tt.code, + "msg": "role request failed", + "log_id": "log-role-hint", + }, errclass.ClassifyContext{Identity: "user"}) + err = withRoleErrorHint(err, tt.operation) + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %#v, want typed problem", err) + } + if problem.Code != tt.code || problem.LogID != "log-role-hint" || problem.Retryable { + t.Fatalf("problem envelope changed: %+v", problem) + } + if !strings.Contains(problem.Hint, tt.wantHint) { + t.Fatalf("hint = %q, want substring %q", problem.Hint, tt.wantHint) + } + if tt.forbid != "" && strings.Contains(problem.Hint, tt.forbid) { + t.Fatalf("hint = %q, must not contain %q", problem.Hint, tt.forbid) + } + }) + } +} + +func TestWithRoleErrorHintPreservesServerDetail(t *testing.T) { + err := errclass.BuildAPIError(map[string]any{ + "code": roleErrInvalidRoleName, + "msg": "invalid role name", + "error": map[string]any{ + "details": []any{map[string]any{"value": "name exceeds the service limit"}}, + }, + }, errclass.ClassifyContext{Identity: "user"}) + err = withRoleErrorHint(err, roleOperationCreate) + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %#v, want typed problem", err) + } + for _, want := range []string{"name exceeds the service limit", "adjust --name"} { + if !strings.Contains(problem.Hint, want) { + t.Fatalf("hint = %q, want %q", problem.Hint, want) + } + } +} + +func TestWithRoleErrorHintPreservesAuthorizationDetail(t *testing.T) { + var err error = errs.NewPermissionError(errs.SubtypePermissionDenied, "administrator access required"). + WithCode(roleErrAdminRequired). + WithHint("server detail: only owners may change this app") + err = withRoleErrorHint(err, roleOperationUpdate) + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %#v, want typed problem", err) + } + for _, want := range []string{"server detail: only owners", "ask an app administrator"} { + if !strings.Contains(problem.Hint, want) { + t.Fatalf("hint = %q, want %q", problem.Hint, want) + } + } +} diff --git a/shortcuts/apps/apps_role_member.go b/shortcuts/apps/apps_role_member.go new file mode 100644 index 0000000000..bd0b9b98ca --- /dev/null +++ b/shortcuts/apps/apps_role_member.go @@ -0,0 +1,611 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsRoleMemberList lists members of an app role. +var AppsRoleMemberList = common.Shortcut{ + Service: appsService, + Command: "+role-member-list", + Description: "List app role members", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +role-member-list --app-id --role-id ", + "Example: lark-cli apps +role-member-list --app-id --role-id --member-type user", + "When only one member type is requested, pass --member-type user|department|chat instead of filtering the full response", + "--member-type returns only the selected member field; omitted fields are unknown, so omit the flag for pre/post-write baselines", + "--format table renders the CLI-native member_type/member_id table; this command has no --limit or --page-size flag", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true}, + {Name: "role-id", Desc: roleIDRequiredDesc, Required: true}, + {Name: "member-type", Desc: "filter member type", Enum: []string{"user", "department", "chat"}}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := validateRoleID(rctx); err != nil { + return err + } + _, err := buildRoleMemberListParams(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + // Validate already ran and called buildRoleMemberListParams; error is impossible here. + params, _ := buildRoleMemberListParams(rctx) + return common.NewDryRunAPI(). + GET(roleMemberListURL(rctx)). + Desc("List app role members"). + Params(params) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + params, err := buildRoleMemberListParams(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("GET", roleMemberListURL(rctx), params, nil) + memberType, _ := params["member_type"].(string) + if shouldRetryRoleMemberListWithoutFilter(err, memberType) { + fmt.Fprintln(rctx.IO().ErrOut, "warning: the server rejected chat member filtering; retried without the filter and returned only the chats field. Omit --member-type for a complete member baseline.") + data, err = rctx.CallAPITyped("GET", roleMemberListURL(rctx), nil, nil) + } + if err != nil { + return withRoleErrorHint(err, roleOperationMemberList) + } + data, err = normalizeRoleMemberListData(data, memberType) + if err != nil { + return err + } + if memberType != "" { + fmt.Fprintf( + rctx.IO().ErrOut, + "warning: --member-type=%s returns only the selected member field; omitted member fields are unknown. Omit --member-type for a complete member baseline.\n", + memberType, + ) + } + out := roleMemberListOutputData(rctx, data) + rctx.OutFormat(out, nil, func(w io.Writer) { + renderRoleMemberListPretty(w, data) + }) + return nil + }, +} + +// AppsRoleMemberAdd adds members to an app role. +var AppsRoleMemberAdd = common.Shortcut{ + Service: appsService, + Command: "+role-member-add", + Description: "Add app role members", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +role-member-add --app-id --role-id --users ou_x", + "Example: lark-cli apps +role-member-add --app-id --role-id --users ou_x,ou_y --departments od-x --chats oc_x", + "Resolve every name first, then add all resolved users (ou_), departments (od-), and chats (oc_) in one call using the three type-specific flags; if any resolution fails, stop without a partial write", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true}, + {Name: "role-id", Desc: roleIDRequiredDesc, Required: true}, + {Name: "users", Desc: "comma-separated user open IDs; do not pass names or emails"}, + {Name: "departments", Desc: "comma-separated open_department_id values"}, + {Name: "chats", Desc: "comma-separated open_chat_id values"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := validateRoleID(rctx); err != nil { + return err + } + _, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + // Validate already ran and called buildRoleMemberAddBody; error is impossible here. + body, _, _ := buildRoleMemberAddBody(rctx) + return common.NewDryRunAPI(). + POST(roleMemberAddURL(rctx)). + Desc("Add app role members"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + body, _, err := buildRoleMemberAddBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", roleMemberAddURL(rctx), nil, body) + if err != nil { + return withRoleErrorHint(err, roleOperationMemberAdd) + } + data, err = normalizeRoleMemberMutationData(data) + if err != nil { + return err + } + rctx.OutFormat(data, nil, func(w io.Writer) { + renderRoleMemberMutationPretty(w, data) + }) + return nil + }, +} + +// AppsRoleMemberRemove removes members from an app role. +var AppsRoleMemberRemove = common.Shortcut{ + Service: appsService, + Command: "+role-member-remove", + Description: "Remove app role members", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +role-member-remove --app-id --role-id --users ou_x --yes", + "Example: lark-cli apps +role-member-remove --app-id --role-id --all --yes", + "When the user names a member, resolve and verify that exact name before writing; if lookup fails, stop and never infer that the role's only current member is the target", + "--all clears members but does not delete the role; after a confirmed --all operation, use an unfiltered +role-member-list to verify users, departments, and chats are empty", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true}, + {Name: "role-id", Desc: roleIDRequiredDesc, Required: true}, + {Name: "users", Desc: "comma-separated user open IDs; do not pass names or emails"}, + {Name: "departments", Desc: "comma-separated open_department_id values"}, + {Name: "chats", Desc: "comma-separated open_chat_id values"}, + {Name: "all", Type: "bool", Desc: "remove all members from the role"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := validateRoleID(rctx); err != nil { + return err + } + _, _, err := buildRoleMemberRemoveBody(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + // Validate already ran and called buildRoleMemberRemoveBody; error is impossible here. + body, _, _ := buildRoleMemberRemoveBody(rctx) + return common.NewDryRunAPI(). + POST(roleMemberRemoveURL(rctx)). + Desc("Remove app role members"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + body, _, err := buildRoleMemberRemoveBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", roleMemberRemoveURL(rctx), nil, body) + if err != nil { + return withRoleErrorHint(err, roleOperationMemberRemove) + } + data, err = normalizeRoleMemberMutationData(data) + if err != nil { + return err + } + rctx.OutFormat(data, nil, func(w io.Writer) { + renderRoleMemberMutationPretty(w, data) + }) + return nil + }, +} + +// AppsRoleMatchList lists roles matching a user in an app. +var AppsRoleMatchList = common.Shortcut{ + Service: appsService, + Command: "+role-match-list", + Description: "List app roles matching a user", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +role-match-list --app-id --user-id ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true}, + {Name: "user-id", Desc: roleUserIDRequiredDesc, Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := validateRoleAppID(rctx); err != nil { + return err + } + _, err := roleMatchTargetUserID(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + // Validate already ran and called buildRoleMatchListBody; error is impossible here. + body, _ := buildRoleMatchListBody(rctx) + return common.NewDryRunAPI(). + POST(roleMatchListURL(rctx)). + Desc("List app role matches"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + body, err := buildRoleMatchListBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", roleMatchListURL(rctx), nil, body) + if err != nil { + return withRoleErrorHint(err, roleOperationMatchList) + } + out, err := normalizeRoleMatchListData(data) + if err != nil { + return err + } + rctx.OutFormat(out, nil, func(w io.Writer) { + renderRoleMatchListPretty(w, common.GetSlice(out, "roles")) + }) + return nil + }, +} + +func roleMemberListURL(rctx *common.RuntimeContext) string { + return fmt.Sprintf(roleMemberListPath, + validate.EncodePathSegment(roleAppID(rctx)), + validate.EncodePathSegment(roleID(rctx)), + ) +} + +func roleMemberAddURL(rctx *common.RuntimeContext) string { + return fmt.Sprintf(roleMemberAddPath, + validate.EncodePathSegment(roleAppID(rctx)), + validate.EncodePathSegment(roleID(rctx)), + ) +} + +func roleMemberRemoveURL(rctx *common.RuntimeContext) string { + return fmt.Sprintf(roleMemberRemovePath, + validate.EncodePathSegment(roleAppID(rctx)), + validate.EncodePathSegment(roleID(rctx)), + ) +} + +func roleMatchListURL(rctx *common.RuntimeContext) string { + return fmt.Sprintf(roleMatchListPath, validate.EncodePathSegment(roleAppID(rctx))) +} + +func buildRoleMemberListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) { + params := map[string]interface{}{} + if memberType := strings.TrimSpace(rctx.Str("member-type")); memberType != "" { + if _, ok := roleMemberKindForType(memberType); !ok { + return nil, appsValidationParamError("--member-type", "--member-type must be one of user, department, or chat"). + WithHint("omit --member-type to list all member types") + } + params["member_type"] = memberType + } + return params, nil +} + +func shouldRetryRoleMemberListWithoutFilter(err error, memberType string) bool { + if err == nil || memberType != "chat" { + return false + } + problem, ok := errs.ProblemOf(err) + if !ok { + return false + } + if problem.Code == roleErrUnsupportedMemberType || problem.Code == 400004040 { + return true + } + return problem.Code == 2 && strings.Contains(strings.ToLower(problem.Message), "member_type") +} + +func normalizeRoleMemberListData(data map[string]interface{}, memberType string) (map[string]interface{}, error) { + if data == nil { + return nil, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "role member response data must be an object", + ).WithHint("retry the complete member read; do not treat missing, null, or non-object data as an empty role") + } + out := map[string]interface{}{} + for k, v := range data { + out[k] = v + } + // The role service uses an exact empty data object when the requested member + // view is empty. For a filtered request, that proves only the selected group + // is empty; non-selected groups must remain omitted rather than being + // synthesized as empty. + if len(data) == 0 { + if memberType != "" { + kind, _ := roleMemberKindForType(memberType) + out[kind.dataKey] = []string{} + return out, nil + } + for _, kind := range roleMemberKinds { + out[kind.dataKey] = []string{} + } + return out, nil + } + if memberType != "" { + selectedKind, _ := roleMemberKindForType(memberType) + values, err := parseRoleMemberIDs(data, selectedKind) + if err != nil { + return nil, err + } + for _, kind := range roleMemberKinds { + if kind.memberType != memberType { + delete(out, kind.dataKey) + } + } + out[selectedKind.dataKey] = values + return out, nil + } + for _, kind := range roleMemberKinds { + values, err := parseRoleMemberIDs(data, kind) + if err != nil { + return nil, err + } + out[kind.dataKey] = values + } + return out, nil +} + +func parseRoleMemberIDs(data map[string]interface{}, kind roleMemberKind) ([]string, error) { + raw, exists := data[kind.dataKey] + if !exists { + return nil, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "role member response is missing %s", + kind.dataKey, + ).WithHint("retry the member operation; do not treat a missing member group as empty") + } + items, ok := raw.([]interface{}) + if !ok { + if stringItems, stringOK := raw.([]string); stringOK { + items = make([]interface{}, len(stringItems)) + for index, value := range stringItems { + items[index] = value + } + } else { + return nil, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "role member response field %s must be an array of strings", + kind.dataKey, + ).WithHint("retry the member operation; do not use malformed member data as a permission baseline") + } + } + values := make([]string, 0, len(items)) + for index, item := range items { + value, ok := item.(string) + value = strings.TrimSpace(value) + if !ok || value == "" || !strings.HasPrefix(value, kind.prefix) || len(value) == len(kind.prefix) { + return nil, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "role member response field %s contains an invalid ID at index %d", + kind.dataKey, + index, + ).WithHint("retry the member operation; expected open IDs with the documented member-type prefix") + } + values = append(values, value) + } + return values, nil +} + +func normalizeRoleMemberMutationData(data map[string]interface{}) (map[string]interface{}, error) { + if data == nil { + return nil, nil + } + out := map[string]interface{}{} + for key, value := range data { + out[key] = value + } + for _, kind := range roleMemberKinds { + if _, exists := data[kind.dataKey]; !exists { + continue + } + values, err := parseRoleMemberIDs(data, kind) + if err != nil { + return nil, err + } + out[kind.dataKey] = values + } + return out, nil +} + +func buildRoleMemberAddBody(rctx *common.RuntimeContext) (map[string]interface{}, roleMemberGroups, error) { + groups, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats")) + if err != nil { + return nil, groups, err + } + return buildRoleMemberBody(groups), groups, nil +} + +func buildRoleMemberRemoveBody(rctx *common.RuntimeContext) (map[string]interface{}, roleMemberGroups, error) { + if rctx.Bool("all") { + if hasExplicitRoleMemberFlags(rctx) { + return nil, roleMemberGroups{}, appsValidationError("--all cannot be used with --users, --departments, or --chats"). + WithParams(roleMemberRemoveConflictParams(rctx)...). + WithHint("use --all by itself to clear every member, or pass explicit member IDs without --all") + } + return map[string]interface{}{"all": true}, roleMemberGroups{}, nil + } + if !hasExplicitRoleMemberFlags(rctx) { + reason := "provide member IDs or use --all" + return nil, roleMemberGroups{}, appsValidationError("specify members to remove with --users/--departments/--chats, or use --all to clear every member"). + WithParams( + appsInvalidParam("--users", reason), + appsInvalidParam("--departments", reason), + appsInvalidParam("--chats", reason), + appsInvalidParam("--all", reason), + ). + WithHint("pass specific member IDs (e.g. --users ou_x), or use --all to remove all members") + } + groups, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats")) + if err != nil { + return nil, groups, err + } + return buildRoleMemberBody(groups), groups, nil +} + +func roleMemberRemoveConflictParams(rctx *common.RuntimeContext) []errs.InvalidParam { + reason := "cannot be combined with --all" + params := []errs.InvalidParam{appsInvalidParam("--all", "cannot be combined with explicit member flags")} + for _, kind := range roleMemberKinds { + if strings.TrimSpace(rctx.Str(strings.TrimPrefix(kind.flagName, "--"))) != "" { + params = append(params, appsInvalidParam(kind.flagName, reason)) + } + } + return params +} + +func hasExplicitRoleMemberFlags(rctx *common.RuntimeContext) bool { + return strings.TrimSpace(rctx.Str("users")) != "" || + strings.TrimSpace(rctx.Str("departments")) != "" || + strings.TrimSpace(rctx.Str("chats")) != "" +} + +func buildRoleMatchListBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { + targetUserID, err := roleMatchTargetUserID(rctx) + if err != nil { + return nil, err + } + return map[string]interface{}{"target_user_id": targetUserID}, nil +} + +func roleMatchTargetUserID(rctx *common.RuntimeContext) (string, error) { + raw := strings.TrimSpace(rctx.Str("user-id")) + if raw == "" { + return "", appsValidationParamError("--user-id", "--user-id is required"). + WithHint("resolve the user to open_id first, then pass --user-id ") + } + if err := validateMemberID(raw, "--user-id"); err != nil { + return "", err + } + return raw, nil +} + +func roleMemberListOutputData(rctx *common.RuntimeContext, data map[string]interface{}) interface{} { + switch rctx.Format { + case "table", "csv", "ndjson": + return roleMemberRows(data) + default: + return data + } +} + +func roleMemberRows(data map[string]interface{}) []interface{} { + rows := []interface{}{} + addRows := func(memberType string, values []string) { + for _, value := range values { + rows = append(rows, map[string]interface{}{ + "member_type": memberType, + "member_id": value, + }) + } + } + for _, kind := range roleMemberKinds { + addRows(kind.memberType, roleIDValues(data[kind.dataKey])) + } + return rows +} + +func normalizeRoleMatchListData(data map[string]interface{}) (map[string]interface{}, error) { + rawRoles, exists := data["roles"] + roles, ok := rawRoles.([]interface{}) + if !exists || !ok { + return nil, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "role match response field roles must be an array", + ).WithHint("retry the user-role lookup; do not treat a missing or malformed roles field as no matches") + } + if err := validateRoleCollection(roles, "role match response field roles"); err != nil { + return nil, err + } + out := map[string]interface{}{} + for k, v := range data { + out[k] = v + } + out["roles"] = roles + return out, nil +} + +func renderRoleMemberListPretty(w io.Writer, data map[string]interface{}) { + renderRoleMemberGroupsPretty(w, data) +} + +func renderRoleMemberGroupsPretty(w io.Writer, data map[string]interface{}) { + for _, kind := range roleMemberKinds { + value, exists := data[kind.dataKey] + if !exists { + continue + } + renderRoleMemberSection(w, kind.dataKey, roleIDValues(value)) + } +} + +func renderRoleMemberMutationPretty(w io.Writer, data map[string]interface{}) { + renderedGroup := false + for _, kind := range roleMemberKinds { + value, exists := data[kind.dataKey] + if !exists { + continue + } + renderRoleMemberSection(w, kind.dataKey, roleIDValues(value)) + renderedGroup = true + } + if !renderedGroup { + fmt.Fprintln(w, "Role member update accepted; use +role-member-list to verify current members.") + } +} + +func renderRoleMemberSection(w io.Writer, label string, values []string) { + if len(values) == 0 { + fmt.Fprintf(w, "%s: []\n", label) + return + } + fmt.Fprintf(w, "%s:\n", label) + for _, value := range values { + fmt.Fprintf(w, " - %s\n", roleDisplayValue(value)) + } +} + +func roleIDValues(value interface{}) []string { + switch items := value.(type) { + case []string: + out := make([]string, 0, len(items)) + for _, item := range items { + if item = strings.TrimSpace(item); item != "" { + out = append(out, item) + } + } + return out + case []interface{}: + out := make([]string, 0, len(items)) + for _, item := range items { + v, ok := item.(string) + if ok && strings.TrimSpace(v) != "" { + out = append(out, strings.TrimSpace(v)) + } + } + return out + default: + return nil + } +} + +func renderRoleMatchListPretty(w io.Writer, items []interface{}) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ROLE ID\tNAME\tDESCRIPTION") + for _, item := range items { + role, ok := item.(map[string]interface{}) + if !ok { + continue + } + fmt.Fprintf(tw, "%s\t%s\t%s\n", + roleDisplayValue(firstNonEmpty(common.GetString(role, "role_id"), common.GetString(role, "id"))), + roleDisplayValue(common.GetString(role, "name")), + roleDisplayValue(common.GetString(role, "description")), + ) + } + _ = tw.Flush() +} diff --git a/shortcuts/apps/apps_role_member_test.go b/shortcuts/apps/apps_role_member_test.go new file mode 100644 index 0000000000..e75ceabb1c --- /dev/null +++ b/shortcuts/apps/apps_role_member_test.go @@ -0,0 +1,1189 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func roleMemberFlagDefs() map[string]string { + return map[string]string{ + "app-id": "string", + "role-id": "string", + "member-type": "string", + "users": "string", + "departments": "string", + "chats": "string", + "all": "bool", + "user-id": "string", + } +} + +func TestAppsRoleMemberListTipsDescribeServerFilteringAndNativeTable(t *testing.T) { + tips := strings.Join(AppsRoleMemberList.Tips, "\n") + for _, want := range []string{ + "--member-type user|department|chat", + "instead of filtering the full response", + "only the selected member field", + "omitted fields are unknown", + "omit the flag for pre/post-write baselines", + "--format table", + "CLI-native member_type/member_id table", + "no --limit or --page-size", + } { + if !strings.Contains(tips, want) { + t.Fatalf("AppsRoleMemberList tips missing %q: %s", want, tips) + } + } +} + +func TestAppsRoleMemberAddTipsDescribeAtomicTypedResolution(t *testing.T) { + tips := strings.Join(AppsRoleMemberAdd.Tips, "\n") + for _, want := range []string{ + "Resolve every name first", + "users (ou_)", + "departments (od-)", + "chats (oc_)", + "in one call", + "stop without a partial write", + } { + if !strings.Contains(tips, want) { + t.Fatalf("AppsRoleMemberAdd tips missing %q: %s", want, tips) + } + } +} + +func TestAppsRoleMemberRemoveTipsDescribeClearVerification(t *testing.T) { + tips := strings.Join(AppsRoleMemberRemove.Tips, "\n") + for _, want := range []string{ + "resolve and verify that exact name", + "lookup fails, stop", + "only current member is the target", + "--all clears members", + "does not delete the role", + "confirmed --all operation", + "unfiltered +role-member-list", + "users, departments, and chats are empty", + } { + if !strings.Contains(tips, want) { + t.Fatalf("AppsRoleMemberRemove tips missing %q: %s", want, tips) + } + } +} + +func TestAppsRoleMemberDryRunRequestShapes(t *testing.T) { + tests := []struct { + name string + flags map[string]string + sc common.Shortcut + method string + url string + params map[string]interface{} + body map[string]interface{} + }{ + { + name: "member-list", + flags: map[string]string{ + "app-id": " app_x ", "role-id": " role_1 ", "member-type": "user", + }, + sc: AppsRoleMemberList, + method: "GET", + url: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + params: map[string]interface{}{"member_type": "user"}, + }, + { + name: "member-add", + flags: map[string]string{"app-id": "app_x", "role-id": "role_1", "users": "ou_a, ou_b", "departments": "od-a", "chats": "oc_a"}, + sc: AppsRoleMemberAdd, + method: "POST", + url: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_add", + body: map[string]interface{}{ + "users": []interface{}{"ou_a", "ou_b"}, + "departments": []interface{}{"od-a"}, + "chats": []interface{}{"oc_a"}, + }, + }, + { + name: "member-remove", + flags: map[string]string{"app-id": "app_x", "role-id": "role_1", "users": "ou_a", "chats": "oc_a"}, + sc: AppsRoleMemberRemove, + method: "POST", + url: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_remove", + body: map[string]interface{}{ + "users": []interface{}{"ou_a"}, + "chats": []interface{}{"oc_a"}, + }, + }, + { + name: "member-remove-all", + flags: map[string]string{"app-id": "app_x", "role-id": "role_1", "all": "true"}, + sc: AppsRoleMemberRemove, + method: "POST", + url: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_remove", + body: map[string]interface{}{"all": true}, + }, + { + name: "match-list", + flags: map[string]string{"app-id": "app_x", "role-id": "ignored", "user-id": " ou_user "}, + sc: AppsRoleMatchList, + method: "POST", + url: "/open-apis/spark/v1/apps/app_x/user_role_list", + body: map[string]interface{}{"target_user_id": "ou_user"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + call := validatedRoleMemberDryRunCall(t, tt.sc, tt.flags) + if call.Method != tt.method || call.URL != tt.url { + t.Fatalf("call = %s %s, want %s %s", call.Method, call.URL, tt.method, tt.url) + } + assertMapEqual(t, call.Params, tt.params) + assertJSONEquivalent(t, call.Body, tt.body) + if tt.name == "match-list" { + if call.Params != nil { + t.Fatalf("match-list must use body instead of query params: %#v", call.Params) + } + if _, ok := call.Body.(map[string]interface{})["role_id"]; ok { + t.Fatalf("match-list body must not include role_id: %#v", call.Body) + } + } + }) + } +} + +func TestAppsRoleMemberRemoveAllRejectsExplicitMembers(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + "all": "true", + "users": "ou_a", + }) + assertRoleValidationParams(t, AppsRoleMemberRemove.Validate(context.Background(), rctx), "--all", "--users") +} + +func TestAppsRoleMemberAddRejectsNoMembers(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + }) + assertRoleValidationParams(t, AppsRoleMemberAdd.Validate(context.Background(), rctx), "--users", "--departments", "--chats") +} + +func TestAppsRoleMemberRemoveRejectsNoMembersOrAll(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + }) + assertRoleValidationParams(t, AppsRoleMemberRemove.Validate(context.Background(), rctx), "--users", "--departments", "--chats", "--all") +} + +func TestAppsRoleMemberRequiredInputsUseTypedValidation(t *testing.T) { + tests := []struct { + name string + sc common.Shortcut + flags map[string]string + param string + }{ + { + name: "member list missing role id", + sc: AppsRoleMemberList, + flags: map[string]string{"app-id": "app_x"}, + param: "--role-id", + }, + { + name: "member add missing app id", + sc: AppsRoleMemberAdd, + flags: map[string]string{"role-id": "role_1", "users": "ou_a"}, + param: "--app-id", + }, + { + name: "match list missing app id", + sc: AppsRoleMatchList, + flags: map[string]string{"user-id": "ou_user"}, + param: "--app-id", + }, + { + name: "match list missing user id", + sc: AppsRoleMatchList, + flags: map[string]string{"app-id": "app_x"}, + param: "--user-id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleMemberFlagDefs(), tt.flags) + assertRoleValidationParam(t, tt.sc.Validate(context.Background(), rctx), tt.param) + }) + } +} + +func TestAppsRoleMatchListRejectsMissingUserID(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "user-id": " ", + }) + assertRoleValidationParam(t, AppsRoleMatchList.Validate(context.Background(), rctx), "--user-id") +} + +func TestAppsRoleMemberRejectsOverflowOnlyChats(t *testing.T) { + chats := make([]string, maxRoleMembers+1) + for i := range chats { + chats[i] = "oc_test" + } + rctx, _, _ := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + "chats": strings.Join(chats, ","), + }) + assertRoleValidationParams(t, AppsRoleMemberAdd.Validate(context.Background(), rctx), "--chats") +} + +func TestAppsRoleMemberListExecuteGroups(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": " app_x ", + "role-id": " role_1 ", + "member-type": "chat", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + OnMatch: func(req *http.Request) { + q := req.URL.Query() + if q.Get("member_type") != "chat" || q.Get("limit") != "" || q.Get("offset") != "" { + t.Fatalf("query = %s, want only member_type=chat", req.URL.RawQuery) + } + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "users": []interface{}{"ou_a"}, + "departments": []interface{}{"od-a"}, + "chats": []interface{}{"oc_a"}, + "trace_id": "kept", + }, + }, + }) + + if err := AppsRoleMemberList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + for _, key := range []string{"users", "departments"} { + if _, exists := env.Data[key]; exists { + t.Fatalf("filtered output must omit unknown %s: %#v", key, env.Data) + } + } + assertStringList(t, env.Data["chats"], []string{"oc_a"}) + if env.Data["trace_id"] != "kept" { + t.Fatalf("backend field lost: %#v", env.Data) + } + stderr, ok := rctx.IO().ErrOut.(*bytes.Buffer) + if !ok { + t.Fatalf("stderr = %T, want *bytes.Buffer", rctx.IO().ErrOut) + } + for _, want := range []string{ + "warning: --member-type=chat returns only the selected member field", + "omitted member fields are unknown", + "complete member baseline", + } { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("stderr missing %q: %q", want, stderr.String()) + } + } +} + +func TestAppsRoleMemberListChatFilterFallsBackToUnfilteredQuery(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + "member-type": "chat", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + OnMatch: func(req *http.Request) { + if got := req.URL.Query().Get("member_type"); got != "chat" { + t.Fatalf("first member_type = %q, want chat", got) + } + }, + Body: map[string]interface{}{ + "code": roleErrUnsupportedMemberType, + "msg": "参数错误:不支持的 member_type", + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + OnMatch: func(req *http.Request) { + if got := req.URL.Query().Get("member_type"); got != "" { + t.Fatalf("fallback member_type = %q, want empty", got) + } + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "users": []interface{}{"ou_a"}, + "departments": []interface{}{"od-a"}, + "chats": []interface{}{"oc_a"}, + }, + }, + }) + + if err := AppsRoleMemberList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + for _, key := range []string{"users", "departments"} { + if _, exists := env.Data[key]; exists { + t.Fatalf("fallback projection must omit unknown %s: %#v", key, env.Data) + } + } + assertStringList(t, env.Data["chats"], []string{"oc_a"}) + stderr, ok := rctx.IO().ErrOut.(*bytes.Buffer) + if !ok { + t.Fatalf("stderr = %T, want *bytes.Buffer", rctx.IO().ErrOut) + } + for _, want := range []string{"warning:", "only the chats field", "complete member baseline"} { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("stderr missing %q: %q", want, stderr.String()) + } + } +} + +func TestAppsRoleMemberListFilteredResponseRequiresOnlySelectedGroup(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + "member-type": "chat", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "chats": []interface{}{"oc_a"}, + "trace_id": "kept", + }, + }, + }) + + if err := AppsRoleMemberList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + assertStringList(t, env.Data["chats"], []string{"oc_a"}) + for _, key := range []string{"users", "departments"} { + if _, exists := env.Data[key]; exists { + t.Fatalf("filtered response must omit %s: %#v", key, env.Data) + } + } + if env.Data["trace_id"] != "kept" { + t.Fatalf("backend metadata lost: %#v", env.Data) + } +} + +func TestShouldRetryRoleMemberListWithoutFilterIsNarrow(t *testing.T) { + documentedUnsupported := errs.NewAPIError(errs.SubtypeInvalidParameters, "unsupported member type").WithCode(roleErrUnsupportedMemberType) + if !shouldRetryRoleMemberListWithoutFilter(documentedUnsupported, "chat") { + t.Fatal("documented chat member_type incompatibility should use the unfiltered fallback") + } + currentUnsupported := errs.NewAPIError(errs.SubtypeUnknown, "parameter error").WithCode(400004040) + if !shouldRetryRoleMemberListWithoutFilter(currentUnsupported, "chat") { + t.Fatal("current chat member_type incompatibility should use the unfiltered fallback") + } + legacyUnsupported := errs.NewAPIError(errs.SubtypeUnknown, "unsupported member_type").WithCode(2) + if !shouldRetryRoleMemberListWithoutFilter(legacyUnsupported, "chat") { + t.Fatal("chat member_type incompatibility should use the unfiltered fallback") + } + if shouldRetryRoleMemberListWithoutFilter(currentUnsupported, "user") { + t.Fatal("user filter must not use the chat-only compatibility fallback") + } + unrelated := errs.NewAPIError(errs.SubtypeUnknown, "invalid role_id").WithCode(2) + if shouldRetryRoleMemberListWithoutFilter(unrelated, "chat") { + t.Fatal("unrelated code=2 errors must pass through unchanged") + } +} + +func TestAppsRoleMemberListRejectsIncompleteOrMalformedBackendData(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + }{ + {name: "missing chats", data: map[string]interface{}{"users": []interface{}{}, "departments": []interface{}{}}}, + {name: "users is not an array", data: map[string]interface{}{"users": "ou_a", "departments": []interface{}{}, "chats": []interface{}{}}}, + {name: "users contains non-string", data: map[string]interface{}{"users": []interface{}{7}, "departments": []interface{}{}, "chats": []interface{}{}}}, + {name: "department has wrong prefix", data: map[string]interface{}{"users": []interface{}{}, "departments": []interface{}{"ou_a"}, "chats": []interface{}{}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": tt.data, + }, + }) + + err := AppsRoleMemberList.Execute(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Execute() = %#v, want internal/invalid_response", err) + } + }) + } +} + +func TestAppsRoleMemberListRejectsMissingNullOrNonObjectData(t *testing.T) { + tests := []struct { + name string + body map[string]interface{} + }{ + {name: "missing", body: map[string]interface{}{"code": 0, "msg": ""}}, + {name: "null", body: map[string]interface{}{"code": 0, "msg": "", "data": nil}}, + {name: "array", body: map[string]interface{}{"code": 0, "msg": "", "data": []interface{}{}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + Body: tt.body, + }) + + err := AppsRoleMemberList.Execute(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Execute() = %#v, want internal/invalid_response", err) + } + }) + } +} + +func TestAppsRoleMemberListNormalizesVerifiedEmptyDataSentinel(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{}, + }, + }) + + if err := AppsRoleMemberList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + assertStringList(t, env.Data["users"], []string{}) + assertStringList(t, env.Data["departments"], []string{}) + assertStringList(t, env.Data["chats"], []string{}) +} + +func TestAppsRoleMemberListFilteredEmptyDataOnlySynthesizesSelectedGroup(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + "member-type": "chat", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{}, + }, + }) + + if err := AppsRoleMemberList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + assertStringList(t, env.Data["chats"], []string{}) + for _, key := range []string{"users", "departments"} { + if _, exists := env.Data[key]; exists { + t.Fatalf("filtered empty response must omit unknown %s: %#v", key, env.Data) + } + } +} + +func TestAppsRoleMemberListPreservesExplicitEmptyGroups(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "users": []interface{}{}, + "departments": []interface{}{}, + "chats": []interface{}{}, + }, + }, + }) + + if err := AppsRoleMemberList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + assertStringList(t, env.Data["users"], []string{}) + assertStringList(t, env.Data["departments"], []string{}) + assertStringList(t, env.Data["chats"], []string{}) +} + +func TestAppsRoleMemberListPrettyOutput(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "users": []interface{}{"ou_a", "ou_b"}, + "departments": []interface{}{"od-a"}, + "chats": []interface{}{}, + }, + }, + }) + + if err := runAppsShortcut(t, AppsRoleMemberList, + []string{"+role-member-list", "--app-id", "app_x", "--role-id", "role_1", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"users:", " - ou_a", " - ou_b", "departments:", " - od-a", "chats: []"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q: %q", want, got) + } + } +} + +func TestAppsRoleMemberListFilteredPrettyOmitsUnknownGroups(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "users": []interface{}{"ou_a"}, + "departments": []interface{}{"od-a"}, + "chats": []interface{}{"oc_a"}, + }, + }, + }) + + if err := runAppsShortcut(t, AppsRoleMemberList, + []string{"+role-member-list", "--app-id", "app_x", "--role-id", "role_1", "--member-type", "chat", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "chats:\n - oc_a") { + t.Fatalf("pretty output missing selected chats: %q", got) + } + for _, absent := range []string{"users:", "departments:"} { + if strings.Contains(got, absent) { + t.Fatalf("pretty output must omit unknown group %q: %q", absent, got) + } + } +} + +func TestAppsRoleMemberListStructuredFormatsUseMemberRows(t *testing.T) { + tests := []struct { + format string + wants []string + }{ + {"table", []string{"member_id", "member_type", "user", "ou_a", "department", "od-a", "chat", "oc_a"}}, + {"csv", []string{"member_id,member_type", "ou_a,user", "od-a,department", "oc_a,chat"}}, + {"ndjson", []string{`"member_type":"user"`, `"member_id":"ou_a"`, `"member_type":"department"`, `"member_id":"od-a"`, `"member_type":"chat"`, `"member_id":"oc_a"`}}, + } + + for _, tt := range tests { + t.Run(tt.format, func(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_list", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "users": []interface{}{"ou_a"}, + "departments": []interface{}{"od-a"}, + "chats": []interface{}{"oc_a"}, + }, + }, + }) + + if err := runAppsShortcut(t, AppsRoleMemberList, + []string{"+role-member-list", "--app-id", "app_x", "--role-id", "role_1", "--format", tt.format, "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if strings.Contains(got, "(empty)") { + t.Fatalf("%s output must not be empty when users/departments/chats exist: %q", tt.format, got) + } + for _, want := range tt.wants { + if !strings.Contains(got, want) { + t.Fatalf("%s output missing %q: %q", tt.format, want, got) + } + } + }) + } +} + +func TestAppsRoleMemberAddExecuteSendsGroupedBody(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + "users": "ou_request", + "departments": "od-request", + "chats": "oc_request", + }) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_add", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "users": []interface{}{"ou_backend"}, + "departments": []interface{}{"od-backend"}, + "chats": []interface{}{"oc_backend"}, + "audit_id": "audit_123", + }, + }, + } + reg.Register(stub) + + if err := AppsRoleMemberAdd.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + assertJSONEquivalent(t, mustDecodeJSON(t, stub.CapturedBody), map[string]interface{}{ + "users": []interface{}{"ou_request"}, + "departments": []interface{}{"od-request"}, + "chats": []interface{}{"oc_request"}, + }) + env := decodeRoleEnvelope(t, stdoutBuf.String()) + if env.Data["audit_id"] != "audit_123" { + t.Fatalf("backend field lost: %#v", env.Data) + } + assertStringList(t, env.Data["users"], []string{"ou_backend"}) + assertStringList(t, env.Data["departments"], []string{"od-backend"}) + assertStringList(t, env.Data["chats"], []string{"oc_backend"}) +} + +func TestAppsRoleMemberAddPrettyOutput(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_add", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "users": []interface{}{"ou_backend"}, + "chats": []interface{}{"oc_backend"}, + }, + }, + }) + + if err := runAppsShortcut(t, AppsRoleMemberAdd, + []string{"+role-member-add", "--app-id", "app_x", "--role-id", "role_1", "--users", "ou_request", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"users:", " - ou_backend", "chats:", " - oc_backend"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q: %q", want, got) + } + } + if strings.Contains(got, "departments:") { + t.Fatalf("pretty output must omit member groups absent from the write response: %q", got) + } +} + +func TestAppsRoleMemberAddPreservesBackendDataOnly(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + "users": "ou_request", + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_add", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "audit_id": "audit_123", + "status": "accepted", + }, + }, + }) + + if err := AppsRoleMemberAdd.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + if env.Data["audit_id"] != "audit_123" || env.Data["status"] != "accepted" { + t.Fatalf("backend fields lost: %#v", env.Data) + } + for _, key := range []string{"users", "departments", "chats"} { + if _, ok := env.Data[key]; ok { + t.Fatalf("%s should not be derived from requested members: %#v", key, env.Data) + } + } +} + +func TestAppsRoleMemberMutationsRejectMalformedReturnedGroups(t *testing.T) { + tests := []struct { + name string + sc common.Shortcut + url string + flags map[string]string + badData map[string]interface{} + }{ + { + name: "add truncating numeric user id", + sc: AppsRoleMemberAdd, + url: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_add", + flags: map[string]string{"app-id": "app_x", "role-id": "role_1", "users": "ou_request"}, + badData: map[string]interface{}{ + "users": []interface{}{1.9}, + }, + }, + { + name: "remove scalar chat id", + sc: AppsRoleMemberRemove, + url: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_remove", + flags: map[string]string{"app-id": "app_x", "role-id": "role_1", "chats": "oc_request"}, + badData: map[string]interface{}{ + "chats": "oc_backend", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleMemberFlagDefs(), tt.flags) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: tt.url, + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": tt.badData, + }, + }) + + err := tt.sc.Execute(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Execute() = %#v, want internal/invalid_response", err) + } + if stdoutBuf.Len() != 0 { + t.Fatalf("malformed mutation response must not produce stdout: %q", stdoutBuf.String()) + } + }) + } +} + +func TestAppsRoleMemberRemovePrettyOutput(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_remove", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "users": []interface{}{"ou_backend"}, + }, + }, + }) + + if err := runAppsShortcut(t, AppsRoleMemberRemove, + []string{"+role-member-remove", "--app-id", "app_x", "--role-id", "role_1", "--users", "ou_request", "--yes", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"users:", " - ou_backend"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q: %q", want, got) + } + } + for _, absent := range []string{"departments:", "chats:"} { + if strings.Contains(got, absent) { + t.Fatalf("pretty output must omit absent group %q: %q", absent, got) + } + } +} + +func TestAppsRoleMemberMutationPrettyWithoutGroupsRequiresReadback(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_add", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "audit_id": "audit_123", + }, + }, + }) + + if err := runAppsShortcut(t, AppsRoleMemberAdd, + []string{"+role-member-add", "--app-id", "app_x", "--role-id", "role_1", "--users", "ou_request", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "+role-member-list") { + t.Fatalf("pretty output must require an independent member readback: %q", got) + } + for _, absent := range []string{"users:", "departments:", "chats:"} { + if strings.Contains(got, absent) { + t.Fatalf("pretty output must not synthesize absent group %q: %q", absent, got) + } + } +} + +func TestAppsRoleMemberRemoveAllExecuteSendsAllAndPreservesBackendData(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + "all": "true", + }) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1/member_remove", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "removed": true, + "audit_id": "audit_456", + }, + }, + } + reg.Register(stub) + + if err := AppsRoleMemberRemove.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + assertJSONEquivalent(t, mustDecodeJSON(t, stub.CapturedBody), map[string]interface{}{"all": true}) + env := decodeRoleEnvelope(t, stdoutBuf.String()) + if env.Data["removed"] != true || env.Data["audit_id"] != "audit_456" { + t.Fatalf("remove all output should preserve backend data, got: %#v", env.Data) + } +} + +func TestAppsRoleMatchListAPIErrorGetsAppsHint(t *testing.T) { + rctx, _, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "user-id": "ou_user", + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/user_role_list", + OnMatch: func(req *http.Request) { + assertJSONEquivalent(t, mustDecodeJSON(t, readRequestBody(t, req)), map[string]interface{}{ + "target_user_id": "ou_user", + }) + }, + Body: map[string]interface{}{ + "code": 99991663, + "msg": "permission denied", + }, + }) + + err := AppsRoleMatchList.Execute(context.Background(), rctx) + if err == nil { + t.Fatalf("Execute() = nil, want API error") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %#v, want typed problem", err) + } + if problem.Category != errs.CategoryAuthentication || problem.Subtype != errs.SubtypeTokenInvalid || problem.Code != 99991663 { + t.Fatalf("problem = %+v, want authentication/token_invalid code 99991663", problem) + } + if problem.Hint != roleMatchHint { + t.Fatalf("hint = %q, want %q", problem.Hint, roleMatchHint) + } +} + +func TestAppsRoleMatchListPrettyOutput(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/user_role_list", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "roles": []interface{}{ + map[string]interface{}{ + "role_id": "role_1", + "name": "Admin", + "description": "System role", + "member_count": 99, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsRoleMatchList, + []string{"+role-match-list", "--app-id", "app_x", "--user-id", "ou_user", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"ROLE ID", "NAME", "DESCRIPTION", "role_1", "Admin", "System role"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q: %q", want, got) + } + } + if strings.Contains(got, "MEMBERS") { + t.Fatalf("pretty output must not include member count column: %q", got) + } + if strings.Contains(got, "99") { + t.Fatalf("pretty output must not display member_count value: %q", got) + } +} + +func TestAppsRoleMatchListJSONPreservesMemberCount(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "user-id": "ou_user", + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/user_role_list", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "roles": []interface{}{ + map[string]interface{}{ + "role_id": "role_1", + "name": "Admin", + "description": "System role", + "member_count": 99, + }, + }, + "trace_id": "kept", + }, + }, + }) + + if err := AppsRoleMatchList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + if env.Data["trace_id"] != "kept" { + t.Fatalf("top-level backend fields should be preserved: %#v", env.Data) + } + roles, ok := env.Data["roles"].([]interface{}) + if !ok || len(roles) != 1 { + t.Fatalf("roles = %#v, want one role", env.Data["roles"]) + } + role, ok := roles[0].(map[string]interface{}) + if !ok { + t.Fatalf("role = %#v, want object", roles[0]) + } + if role["member_count"] != float64(99) { + t.Fatalf("role-match-list JSON must preserve member_count: %#v", role) + } + if role["role_id"] != "role_1" || role["name"] != "Admin" { + t.Fatalf("role fields lost: %#v", role) + } +} + +func TestAppsRoleMatchListPreservesExplicitEmptyRoles(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "user-id": "ou_user", + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/user_role_list", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{"roles": []interface{}{}, "trace_id": "kept"}, + }, + }) + + if err := AppsRoleMatchList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + roles, ok := env.Data["roles"].([]interface{}) + if !ok || len(roles) != 0 { + t.Fatalf("roles = %#v, want explicit empty array", env.Data["roles"]) + } + if env.Data["trace_id"] != "kept" { + t.Fatalf("top-level backend fields should be preserved: %#v", env.Data) + } +} + +func TestAppsRoleMatchListRejectsMalformedRoles(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + }{ + {name: "missing roles", data: map[string]interface{}{}}, + {name: "roles is not array", data: map[string]interface{}{"roles": "role_1"}}, + {name: "role is not object", data: map[string]interface{}{"roles": []interface{}{"role_1"}}}, + {name: "role missing role id", data: map[string]interface{}{"roles": []interface{}{map[string]interface{}{"name": "Admin"}}}}, + {name: "legacy id does not replace role id", data: map[string]interface{}{"roles": []interface{}{map[string]interface{}{"id": "role_1", "name": "Admin"}}}}, + {name: "role id is empty", data: map[string]interface{}{"roles": []interface{}{map[string]interface{}{"role_id": " ", "name": "Admin"}}}}, + {name: "role id is not string", data: map[string]interface{}{"roles": []interface{}{map[string]interface{}{"role_id": 1, "name": "Admin"}}}}, + {name: "role missing name", data: map[string]interface{}{"roles": []interface{}{map[string]interface{}{"role_id": "role_1"}}}}, + {name: "name is empty", data: map[string]interface{}{"roles": []interface{}{map[string]interface{}{"role_id": "role_1", "name": " "}}}}, + {name: "name is not string", data: map[string]interface{}{"roles": []interface{}{map[string]interface{}{"role_id": "role_1", "name": 1}}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, reg := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "user-id": "ou_user", + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/user_role_list", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": tt.data, + }, + }) + + err := AppsRoleMatchList.Execute(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Execute() = %#v, want internal/invalid_response", err) + } + }) + } +} + +func TestAppsRoleMatchListRejectsNonOpenUserID(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleMemberFlagDefs(), map[string]string{ + "app-id": "app_x", + "user-id": "alice@example.com", + }) + assertRoleValidationParam(t, AppsRoleMatchList.Validate(context.Background(), rctx), "--user-id") +} + +func validatedRoleMemberDryRunCall(t *testing.T, sc common.Shortcut, flags map[string]string) dryRunCall { + t.Helper() + rctx, _, _ := newRoleRCtx(t, roleMemberFlagDefs(), flags) + if sc.Validate != nil { + if err := sc.Validate(context.Background(), rctx); err != nil { + t.Fatalf("Validate() = %v", err) + } + } + raw, err := json.Marshal(sc.DryRun(context.Background(), rctx)) + if err != nil { + t.Fatalf("marshal dry-run: %v", err) + } + return firstDryRunCall(t, raw) +} + +func assertJSONEquivalent(t *testing.T, got, want interface{}) { + t.Helper() + gotNorm := normalizeJSONValue(t, got) + wantNorm := normalizeJSONValue(t, want) + if !reflect.DeepEqual(gotNorm, wantNorm) { + t.Fatalf("json value = %#v, want %#v", gotNorm, wantNorm) + } +} + +func normalizeJSONValue(t *testing.T, value interface{}) interface{} { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal json value: %v", err) + } + return mustDecodeJSON(t, raw) +} + +func mustDecodeJSON(t *testing.T, raw []byte) interface{} { + t.Helper() + var value interface{} + if len(raw) == 0 { + t.Fatalf("empty JSON body") + } + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("unmarshal JSON %s: %v", raw, err) + } + return value +} + +func readRequestBody(t *testing.T, req *http.Request) []byte { + t.Helper() + raw, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("read request body: %v", err) + } + return raw +} + +func assertStringList(t *testing.T, value interface{}, want []string) { + t.Helper() + got := roleIDValues(value) + if !reflect.DeepEqual(got, want) { + t.Fatalf("list = %#v, want %#v", got, want) + } +} diff --git a/shortcuts/apps/apps_role_test.go b/shortcuts/apps/apps_role_test.go new file mode 100644 index 0000000000..fe47470f86 --- /dev/null +++ b/shortcuts/apps/apps_role_test.go @@ -0,0 +1,1320 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func roleCRUDFlagDefs() map[string]string { + return map[string]string{ + "app-id": "string", + "role-id": "string", + "name": "string", + "description": "string", + "page-size": "int", + "page-token": "string", + } +} + +func TestAppsRoleMetadata(t *testing.T) { + tests := []struct { + name string + command string + risk string + scopes []string + }{ + {"list", AppsRoleList.Command, AppsRoleList.Risk, AppsRoleList.Scopes}, + {"get", AppsRoleGet.Command, AppsRoleGet.Risk, AppsRoleGet.Scopes}, + {"create", AppsRoleCreate.Command, AppsRoleCreate.Risk, AppsRoleCreate.Scopes}, + {"update", AppsRoleUpdate.Command, AppsRoleUpdate.Risk, AppsRoleUpdate.Scopes}, + {"delete", AppsRoleDelete.Command, AppsRoleDelete.Risk, AppsRoleDelete.Scopes}, + } + wantCommands := map[string]string{ + "list": "+role-list", + "get": "+role-get", + "create": "+role-create", + "update": "+role-update", + "delete": "+role-delete", + } + wantRisks := map[string]string{ + "list": "read", + "get": "read", + "create": "write", + "update": "write", + "delete": "high-risk-write", + } + wantScopes := map[string]string{ + "list": "spark:app:read", + "get": "spark:app:read", + "create": "spark:app:write", + "update": "spark:app:write", + "delete": "spark:app:write", + } + for _, tt := range tests { + if tt.command != wantCommands[tt.name] { + t.Fatalf("%s command = %q, want %q", tt.name, tt.command, wantCommands[tt.name]) + } + if tt.risk != wantRisks[tt.name] { + t.Fatalf("%s risk = %q, want %q", tt.name, tt.risk, wantRisks[tt.name]) + } + if len(tt.scopes) != 1 || tt.scopes[0] != wantScopes[tt.name] { + t.Fatalf("%s scopes = %#v, want [%s]", tt.name, tt.scopes, wantScopes[tt.name]) + } + } + for name, shortcut := range map[string]struct { + authTypes []string + hasFormat bool + }{ + "list": {AppsRoleList.AuthTypes, AppsRoleList.HasFormat}, + "get": {AppsRoleGet.AuthTypes, AppsRoleGet.HasFormat}, + "create": {AppsRoleCreate.AuthTypes, AppsRoleCreate.HasFormat}, + "update": {AppsRoleUpdate.AuthTypes, AppsRoleUpdate.HasFormat}, + "delete": {AppsRoleDelete.AuthTypes, AppsRoleDelete.HasFormat}, + } { + if len(shortcut.authTypes) != 1 || shortcut.authTypes[0] != "user" { + t.Fatalf("%s authTypes = %#v, want [user]", name, shortcut.authTypes) + } + if !shortcut.hasFormat { + t.Fatalf("%s HasFormat = false, want true", name) + } + } +} + +func TestAppsRoleIDFlagHelpIdentifiesAcceptedIDKinds(t *testing.T) { + shortcuts := []common.Shortcut{ + AppsRoleList, + AppsRoleGet, + AppsRoleCreate, + AppsRoleUpdate, + AppsRoleDelete, + AppsRoleMemberList, + AppsRoleMemberAdd, + AppsRoleMemberRemove, + AppsRoleMatchList, + } + for _, shortcut := range shortcuts { + found := false + for _, flag := range shortcut.Flags { + if flag.Name != "app-id" { + continue + } + found = true + if flag.Desc != roleAppIDRequiredDesc { + t.Fatalf("%s --app-id description = %q, want %q", shortcut.Command, flag.Desc, roleAppIDRequiredDesc) + } + } + if !found { + t.Fatalf("%s is missing --app-id", shortcut.Command) + } + } + + for _, flag := range AppsRoleMatchList.Flags { + if flag.Name == "user-id" { + if flag.Desc != roleUserIDRequiredDesc { + t.Fatalf("%s --user-id description = %q, want %q", AppsRoleMatchList.Command, flag.Desc, roleUserIDRequiredDesc) + } + return + } + } + t.Fatalf("%s is missing --user-id", AppsRoleMatchList.Command) +} + +func TestAppsRoleListTipsDescribeSafeNameResolution(t *testing.T) { + tips := strings.Join(AppsRoleList.Tips, "\n") + for _, want := range []string{"--name", "exact", "unique role_id", "+role-get"} { + if !strings.Contains(tips, want) { + t.Fatalf("AppsRoleList tips missing %q: %s", want, tips) + } + } +} + +func TestAppsRoleGetTipsDescribeSafeNameResolution(t *testing.T) { + tips := strings.Join(AppsRoleGet.Tips, "\n") + for _, want := range []string{"not a human-readable role name", "+role-list --name", "unique returned role_id"} { + if !strings.Contains(tips, want) { + t.Fatalf("AppsRoleGet tips missing %q: %s", want, tips) + } + } + for _, want := range []string{"--name ", "unique returned role_id"} { + if !strings.Contains(roleItemHint, want) { + t.Fatalf("roleItemHint missing %q: %s", want, roleItemHint) + } + } +} + +func TestAppsRoleDeleteTipsRequireExplicitConfirmationAndConditionalReadback(t *testing.T) { + tips := strings.Join(AppsRoleDelete.Tips, "\n") + for _, want := range []string{ + "delete request alone is not explicit confirmation", + "current member scope", + "use --yes only after", + "When independent verification is required", + "+role-list --name", + "confirm the deleted role_id is absent", + "failed +role-get alone does not prove deletion", + } { + if !strings.Contains(tips, want) { + t.Fatalf("AppsRoleDelete tips missing %q: %s", want, tips) + } + } +} + +func TestAppsRoleCreateTipsDescribeConditionalIndependentReadback(t *testing.T) { + tips := strings.Join(AppsRoleCreate.Tips, "\n") + for _, want := range []string{ + "create response returns data.role", + "data.role.role_id", + "only when independent verification is required", + } { + if !strings.Contains(tips, want) { + t.Fatalf("AppsRoleCreate tips missing %q: %s", want, tips) + } + } +} + +func TestAppsRoleDryRunRequestShapes(t *testing.T) { + tests := []struct { + name string + flags map[string]string + sc common.Shortcut + method string + url string + params map[string]interface{} + body map[string]interface{} + }{ + { + name: "list", + flags: map[string]string{ + "app-id": "app_x", "name": " Admin ", "page-size": "20", "page-token": "40", + }, + sc: AppsRoleList, + method: "GET", + url: "/open-apis/spark/v1/apps/app_x/roles", + params: map[string]interface{}{"limit": float64(100), "offset": float64(0), "name": "Admin"}, + }, + { + name: "get", + flags: map[string]string{"app-id": "app_x", "role-id": "role_1"}, + sc: AppsRoleGet, + method: "GET", + url: "/open-apis/spark/v1/apps/app_x/roles/role_1", + }, + { + name: "create", + flags: map[string]string{"app-id": "app_x", "name": " Admin ", "description": " Manage "}, + sc: AppsRoleCreate, + method: "POST", + url: "/open-apis/spark/v1/apps/app_x/roles", + body: map[string]interface{}{"name": "Admin", "description": "Manage"}, + }, + { + name: "update", + flags: map[string]string{"app-id": "app_x", "role-id": "role_1", "description": " New "}, + sc: AppsRoleUpdate, + method: "PATCH", + url: "/open-apis/spark/v1/apps/app_x/roles/role_1", + body: map[string]interface{}{"description": "New"}, + }, + { + name: "delete", + flags: map[string]string{"app-id": "app_x", "role-id": "role_1"}, + sc: AppsRoleDelete, + method: "DELETE", + url: "/open-apis/spark/v1/apps/app_x/roles/role_1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleCRUDFlagDefs(), tt.flags) + if tt.sc.Validate != nil { + if err := tt.sc.Validate(context.Background(), rctx); err != nil { + t.Fatalf("Validate() = %v", err) + } + } + raw, err := json.Marshal(tt.sc.DryRun(context.Background(), rctx)) + if err != nil { + t.Fatalf("marshal dry-run: %v", err) + } + call := firstDryRunCall(t, raw) + if call.Method != tt.method || call.URL != tt.url { + t.Fatalf("call = %s %s, want %s %s", call.Method, call.URL, tt.method, tt.url) + } + assertMapEqual(t, call.Params, tt.params) + assertMapEqual(t, mapFromInterface(t, call.Body), tt.body) + }) + } +} + +func TestAppsRoleCreatePrettyAcceptsRoleIDAcknowledgement(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "role": map[string]interface{}{ + "role_id": "role_backend", + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsRoleCreate, + []string{"+role-create", "--app-id", "app_x", "--name", "Admin", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "Created role role_backend") { + t.Fatalf("pretty output should read role_id from response data.role, got: %q", got) + } + if strings.Contains(got, "Admin") { + t.Fatalf("pretty output must not present the request name as response-confirmed data, got: %q", got) + } +} + +func TestAppsRoleCreateRejectsMismatchedExplicitRoleID(t *testing.T) { + rctx, _, reg := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "name": "Admin", + "role-id": "role_request", + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "role": map[string]interface{}{ + "name": "Admin", + "role_id": "role_backend", + }, + }, + }, + }) + + err := AppsRoleCreate.Execute(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Execute() = %#v, want internal/invalid_response", err) + } +} + +func TestAppsRoleGetPrettyOutput(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "role": map[string]interface{}{ + "role_id": "role_1", + "name": "Admin", + "description": "Manage", + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsRoleGet, + []string{"+role-get", "--app-id", "app_x", "--role-id", "role_1", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"role_id: role_1", "name: Admin", "description: Manage"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q: %q", want, got) + } + } +} + +func TestAppsRoleUpdatePrettyAcceptsRoleIDAcknowledgement(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "role": map[string]interface{}{ + "role_id": "role_1", + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsRoleUpdate, + []string{"+role-update", "--app-id", "app_x", "--role-id", "role_1", "--name", "Operator", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "Updated role role_1") { + t.Fatalf("pretty output = %q, want updated role acknowledgement", got) + } + if strings.Contains(got, "Operator") { + t.Fatalf("pretty output must not present the request name as response-confirmed data, got: %q", got) + } +} + +func TestAppsRoleReadWriteCommandsRejectEmptySuccessData(t *testing.T) { + tests := []struct { + name string + sc common.Shortcut + method string + url string + flags map[string]string + }{ + { + name: "get", + sc: AppsRoleGet, + method: "GET", + url: "/open-apis/spark/v1/apps/app_x/roles/role_1", + flags: map[string]string{"app-id": "app_x", "role-id": "role_1"}, + }, + { + name: "create", + sc: AppsRoleCreate, + method: "POST", + url: "/open-apis/spark/v1/apps/app_x/roles", + flags: map[string]string{"app-id": "app_x", "name": "Admin"}, + }, + { + name: "update", + sc: AppsRoleUpdate, + method: "PATCH", + url: "/open-apis/spark/v1/apps/app_x/roles/role_1", + flags: map[string]string{"app-id": "app_x", "role-id": "role_1", "name": "Operator"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, reg := newRoleRCtx(t, roleCRUDFlagDefs(), tt.flags) + reg.Register(&httpmock.Stub{ + Method: tt.method, + URL: tt.url, + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{}, + }, + }) + + err := tt.sc.Execute(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Execute() = %#v, want internal/invalid_response", err) + } + }) + } +} + +func TestParseRoleDetailResponseDataRejectsMalformedRole(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + }{ + {name: "nil data", data: nil}, + {name: "missing role", data: map[string]interface{}{}}, + {name: "null role", data: map[string]interface{}{"role": nil}}, + {name: "role is not object", data: map[string]interface{}{"role": []interface{}{}}}, + {name: "missing role id", data: map[string]interface{}{"role": map[string]interface{}{"name": "Admin"}}}, + {name: "role id is not string", data: map[string]interface{}{"role": map[string]interface{}{"role_id": 1, "name": "Admin"}}}, + {name: "role id is empty", data: map[string]interface{}{"role": map[string]interface{}{"role_id": " ", "name": "Admin"}}}, + {name: "role id mismatches request", data: map[string]interface{}{"role": map[string]interface{}{"role_id": "role_2", "name": "Admin"}}}, + {name: "missing name", data: map[string]interface{}{"role": map[string]interface{}{"role_id": "role_1"}}}, + {name: "name is not string", data: map[string]interface{}{"role": map[string]interface{}{"role_id": "role_1", "name": 1}}}, + {name: "name is empty", data: map[string]interface{}{"role": map[string]interface{}{"role_id": "role_1", "name": " "}}}, + {name: "description is null", data: map[string]interface{}{"role": map[string]interface{}{"role_id": "role_1", "name": "Admin", "description": nil}}}, + {name: "description is not string", data: map[string]interface{}{"role": map[string]interface{}{"role_id": "role_1", "name": "Admin", "description": 1}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseRoleDetailResponseData(tt.data, "role_1") + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("parseRoleDetailResponseData() = %#v, want internal/invalid_response", err) + } + }) + } +} + +func TestParseRoleWriteResponseDataAcceptsMinimalAcknowledgement(t *testing.T) { + got, err := parseRoleWriteResponseData(map[string]interface{}{ + "role": map[string]interface{}{"role_id": "role_1"}, + }, "role_1") + if err != nil { + t.Fatalf("parseRoleWriteResponseData() error = %v", err) + } + if got.RoleID != "role_1" || got.Name != "" || got.Description != "" { + t.Fatalf("parseRoleWriteResponseData() = %#v, want role_id-only acknowledgement", got) + } +} + +func TestParseRoleWriteResponseDataRejectsMalformedOptionalFields(t *testing.T) { + tests := []struct { + name string + role map[string]interface{} + }{ + {name: "name is null", role: map[string]interface{}{"role_id": "role_1", "name": nil}}, + {name: "name is not string", role: map[string]interface{}{"role_id": "role_1", "name": 1}}, + {name: "name is empty", role: map[string]interface{}{"role_id": "role_1", "name": " "}}, + {name: "description is null", role: map[string]interface{}{"role_id": "role_1", "description": nil}}, + {name: "description is not string", role: map[string]interface{}{"role_id": "role_1", "description": 1}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseRoleWriteResponseData(map[string]interface{}{"role": tt.role}, "role_1") + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("parseRoleWriteResponseData() = %#v, want internal/invalid_response", err) + } + }) + } +} + +func TestAppsRoleDeletePrettyOutputUsesRequestRoleID(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{}, + }, + }) + + if err := runAppsShortcut(t, AppsRoleDelete, + []string{"+role-delete", "--app-id", "app_x", "--role-id", "role_1", "--yes", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "Deleted role role_1") { + t.Fatalf("pretty output = %q, want deleted role summary", got) + } +} + +func TestAppsRoleDeleteNormalizesEmptyBackendData(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + }) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{}, + }, + }) + + if err := AppsRoleDelete.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + if env.Data["role_id"] != "role_1" || env.Data["deleted"] != true { + t.Fatalf("delete output = %#v, want normalized role_id/deleted", env.Data) + } +} + +func TestAppsRoleDeleteRejectsMissingNullOrNonObjectData(t *testing.T) { + tests := []struct { + name string + body map[string]interface{} + }{ + {name: "missing", body: map[string]interface{}{"code": 0, "msg": ""}}, + {name: "null", body: map[string]interface{}{"code": 0, "msg": "", "data": nil}}, + {name: "array", body: map[string]interface{}{"code": 0, "msg": "", "data": []interface{}{}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, reg := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + }) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1", + Body: tt.body, + }) + + err := AppsRoleDelete.Execute(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Execute() = %#v, want internal/invalid_response", err) + } + }) + } +} + +func TestAppsRoleDeleteRejectsContradictoryBackendData(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + }{ + {name: "non-empty response missing role id", data: map[string]interface{}{"deleted": true}}, + {name: "non-empty response missing deleted", data: map[string]interface{}{"role_id": "role_1"}}, + {name: "mismatched role id", data: map[string]interface{}{"role_id": "role_other", "deleted": true}}, + {name: "role id is not a string", data: map[string]interface{}{"role_id": 1, "deleted": true}}, + {name: "explicit false", data: map[string]interface{}{"role_id": "role_1", "deleted": false}}, + {name: "deleted is not boolean", data: map[string]interface{}{"role_id": "role_1", "deleted": "true"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, reg := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + }) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": tt.data, + }, + }) + + err := AppsRoleDelete.Execute(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Execute() = %#v, want internal/invalid_response", err) + } + }) + } +} + +func TestAppsRoleListPrettyDoesNotDisplayMemberCount(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "role_id": "role_1", + "name": "Admin", + "description": "Manage", + "member_count": 12345, + }, + }, + "has_more": false, + "total": 1, + }, + }, + }) + + if err := runAppsShortcut(t, AppsRoleList, + []string{"+role-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if strings.Contains(got, "MEMBERS") || strings.Contains(got, "12345") { + t.Fatalf("pretty output must not display member_count, got: %q", got) + } + if !strings.Contains(got, "ROLE ID") || !strings.Contains(got, "role_1") { + t.Fatalf("pretty output missing role fields: %q", got) + } +} + +func TestAppsRoleCreateBodyOmitsRoleIDUnlessChanged(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "name": "Admin", + }) + body := buildRoleCreateBody(rctx) + if _, ok := body["role_id"]; ok { + t.Fatalf("role_id should be omitted when --role-id is not changed: %#v", body) + } + + rctx, _, _ = newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "name": "Admin", + "role-id": " role_1 ", + }) + body = buildRoleCreateBody(rctx) + if body["role_id"] != "role_1" { + t.Fatalf("role_id = %#v, want role_1; body=%#v", body["role_id"], body) + } +} + +func TestAppsRoleCreateBodyPreservesExplicitEmptyDescription(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "name": "Admin", + "description": "", + }) + body := buildRoleCreateBody(rctx) + description, ok := body["description"] + if !ok { + t.Fatalf("description should be present when --description is explicitly provided: %#v", body) + } + if description != "" { + t.Fatalf("description = %#v, want empty string", description) + } +} + +func TestAppsRoleDryRunPreservesExplicitEmptyDescription(t *testing.T) { + tests := []struct { + name string + sc common.Shortcut + flags map[string]string + }{ + { + name: "create", + sc: AppsRoleCreate, + flags: map[string]string{ + "app-id": "app_x", + "name": "Admin", + "description": "", + }, + }, + { + name: "update", + sc: AppsRoleUpdate, + flags: map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + "description": "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleCRUDFlagDefs(), tt.flags) + if tt.sc.Validate != nil { + if err := tt.sc.Validate(context.Background(), rctx); err != nil { + t.Fatalf("Validate() = %v", err) + } + } + raw, err := json.Marshal(tt.sc.DryRun(context.Background(), rctx)) + if err != nil { + t.Fatalf("marshal dry-run: %v", err) + } + call := firstDryRunCall(t, raw) + body := mapFromInterface(t, call.Body) + description, ok := body["description"] + if !ok { + t.Fatalf("description should be present when explicitly set empty: %#v", body) + } + if description != "" { + t.Fatalf("description = %#v, want empty string", description) + } + }) + } +} + +func TestAppsRoleUpdateBodyOnlyChangedFields(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + "description": " updated ", + }) + body := buildRoleUpdateBody(rctx) + if len(body) != 1 || body["description"] != "updated" { + t.Fatalf("body = %#v, want only trimmed description", body) + } + if _, ok := body["name"]; ok { + t.Fatalf("name should be omitted when unchanged: %#v", body) + } +} + +func TestAppsRoleUpdateEmptyNameValidationParam(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + "name": " ", + }) + assertRoleValidationParam(t, AppsRoleUpdate.Validate(context.Background(), rctx), "--name") +} + +func TestAppsRoleListEmptyNameValidationParam(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "name": " ", + }) + assertRoleValidationParam(t, AppsRoleList.Validate(context.Background(), rctx), "--name") +} + +func TestAppsRoleUpdateMissingNameAndDescriptionValidationParams(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + }) + assertRoleValidationParams(t, AppsRoleUpdate.Validate(context.Background(), rctx), "--name", "--description") +} + +func TestAppsRoleCreateInvalidOptionalRoleIDParam(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "name": "Admin", + "role-id": "bad/role", + }) + assertRoleValidationParam(t, AppsRoleCreate.Validate(context.Background(), rctx), "--role-id") +} + +func TestAppsRoleCreateBlankOptionalRoleIDValidationParam(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "name": "Admin", + "role-id": " ", + }) + assertRoleValidationParam(t, AppsRoleCreate.Validate(context.Background(), rctx), "--role-id") +} + +func TestAppsRoleRequiredInputsUseTypedValidation(t *testing.T) { + tests := []struct { + name string + sc common.Shortcut + flags map[string]string + param string + }{ + { + name: "list missing app id", + sc: AppsRoleList, + flags: map[string]string{}, + param: "--app-id", + }, + { + name: "get missing role id", + sc: AppsRoleGet, + flags: map[string]string{"app-id": "app_x"}, + param: "--role-id", + }, + { + name: "create missing name", + sc: AppsRoleCreate, + flags: map[string]string{"app-id": "app_x"}, + param: "--name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleCRUDFlagDefs(), tt.flags) + assertRoleValidationParam(t, tt.sc.Validate(context.Background(), rctx), tt.param) + }) + } +} + +func TestAppsRoleCreateMissingNameDoesNotSuggestInventingOne(t *testing.T) { + rctx, _, _ := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "description": "Can inspect BI reports", + }) + problem := assertRoleValidationParam(t, AppsRoleCreate.Validate(context.Background(), rctx), "--name") + if !strings.Contains(problem.Hint, "do not infer a name") { + t.Fatalf("hint = %q, want non-invention guidance", problem.Hint) + } +} + +func TestAppsRoleListExecutePagination(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": " app_x ", + "page-size": "2", + "page-token": "4", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles", + OnMatch: func(req *http.Request) { + q := req.URL.Query() + if q.Get("limit") != "2" || q.Get("offset") != "4" || q.Get("name") != "" { + t.Fatalf("query = %s, want limit=2 offset=4", req.URL.RawQuery) + } + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "role_id": "role_1", + "name": "Admin", + "description": "Manage", + "member_count": 3, + }, + }, + "has_more": true, + "page_token": "opaque_backend_token", + "total": "9", + "trace_id": "kept", + }, + }, + }) + + if err := AppsRoleList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + if env.Data["page_token"] != "6" { + t.Fatalf("page_token = %#v, want 6; output=%s", env.Data["page_token"], stdoutBuf.String()) + } + if env.Data["has_more"] != true || env.Data["total"] != float64(9) || env.Data["trace_id"] != "kept" { + t.Fatalf("unexpected normalized data: %#v", env.Data) + } + items, ok := env.Data["items"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("items = %#v, want one role", env.Data["items"]) + } + role, ok := items[0].(map[string]interface{}) + if !ok { + t.Fatalf("role item = %#v, want object", items[0]) + } + if role["member_count"] != float64(3) { + t.Fatalf("role-list JSON should preserve backend member_count, got %#v", role) + } +} + +func TestAppsRoleListNameFilterScansAllPagesAndPaginatesMatches(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "name": "Target", + "page-size": "1", + "page-token": "1", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles", + OnMatch: func(req *http.Request) { + q := req.URL.Query() + if q.Get("limit") != "100" || q.Get("offset") != "0" || q.Get("name") != "Target" { + t.Fatalf("first scan query = %s", req.URL.RawQuery) + } + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"role_id": "role_target_1", "name": "Target"}, + map[string]interface{}{"role_id": "role_other", "name": "Other"}, + }, + "has_more": true, + "total": 3, + "trace_id": "kept", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles", + OnMatch: func(req *http.Request) { + q := req.URL.Query() + if q.Get("limit") != "100" || q.Get("offset") != "100" || q.Get("name") != "Target" { + t.Fatalf("second scan query = %s", req.URL.RawQuery) + } + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"role_id": "role_target_2", "name": "Target"}, + }, + "has_more": false, + "total": 3, + }, + }, + }) + + if err := AppsRoleList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + items, ok := env.Data["items"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("items = %#v, want one paged exact match", env.Data["items"]) + } + role, ok := items[0].(map[string]interface{}) + if !ok || role["role_id"] != "role_target_2" || role["name"] != "Target" { + t.Fatalf("role = %#v, want second exact match", items[0]) + } + if env.Data["total"] != float64(2) || env.Data["has_more"] != false || env.Data["page_token"] != "" || env.Data["trace_id"] != "kept" { + t.Fatalf("normalized filtered output = %#v", env.Data) + } +} + +func TestAppsRoleListRejectsMalformedPageShape(t *testing.T) { + validRole := map[string]interface{}{"role_id": "role_1", "name": "Admin"} + tests := []struct { + name string + data map[string]interface{} + }{ + {name: "missing items", data: map[string]interface{}{"has_more": false, "total": 0}}, + {name: "items is not array", data: map[string]interface{}{"items": "role_1", "has_more": false, "total": 1}}, + {name: "item is not object", data: map[string]interface{}{"items": []interface{}{"role_1"}, "has_more": false, "total": 1}}, + {name: "item missing role id", data: map[string]interface{}{"items": []interface{}{map[string]interface{}{"name": "Admin"}}, "has_more": false, "total": 1}}, + {name: "legacy id does not replace role id", data: map[string]interface{}{"items": []interface{}{map[string]interface{}{"id": "role_1", "name": "Admin"}}, "has_more": false, "total": 1}}, + {name: "role id is empty", data: map[string]interface{}{"items": []interface{}{map[string]interface{}{"role_id": " ", "name": "Admin"}}, "has_more": false, "total": 1}}, + {name: "role id is not string", data: map[string]interface{}{"items": []interface{}{map[string]interface{}{"role_id": 1, "name": "Admin"}}, "has_more": false, "total": 1}}, + {name: "item missing name", data: map[string]interface{}{"items": []interface{}{map[string]interface{}{"role_id": "role_1"}}, "has_more": false, "total": 1}}, + {name: "name is empty", data: map[string]interface{}{"items": []interface{}{map[string]interface{}{"role_id": "role_1", "name": " "}}, "has_more": false, "total": 1}}, + {name: "name is not string", data: map[string]interface{}{"items": []interface{}{map[string]interface{}{"role_id": "role_1", "name": 1}}, "has_more": false, "total": 1}}, + {name: "missing has more", data: map[string]interface{}{"items": []interface{}{}, "total": 0}}, + {name: "has more is not boolean", data: map[string]interface{}{"items": []interface{}{}, "has_more": "false", "total": 0}}, + {name: "missing total", data: map[string]interface{}{"items": []interface{}{}, "has_more": false}}, + {name: "total string has whitespace", data: map[string]interface{}{"items": []interface{}{validRole}, "has_more": false, "total": " 1"}}, + {name: "total string has sign", data: map[string]interface{}{"items": []interface{}{validRole}, "has_more": false, "total": "+1"}}, + {name: "total string is fractional", data: map[string]interface{}{"items": []interface{}{validRole}, "has_more": false, "total": "1.0"}}, + {name: "total string exceeds int range", data: map[string]interface{}{"items": []interface{}{}, "has_more": false, "total": "9223372036854775808"}}, + {name: "total is fractional", data: map[string]interface{}{"items": []interface{}{validRole}, "has_more": false, "total": 1.5}}, + {name: "total is negative", data: map[string]interface{}{"items": []interface{}{}, "has_more": false, "total": -1}}, + {name: "total exceeds int range", data: map[string]interface{}{"items": []interface{}{}, "has_more": false, "total": 9.223372036854776e18}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, reg := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{"app-id": "app_x"}) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": tt.data, + }, + }) + + err := AppsRoleList.Execute(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Execute() = %#v, want internal/invalid_response", err) + } + }) + } +} + +func TestAppsRoleListNameFilterRejectsInvalidPagination(t *testing.T) { + tests := []struct { + name string + secondPage []interface{} + secondHasMore bool + }{ + {name: "empty page", secondPage: []interface{}{}, secondHasMore: true}, + {name: "repeated page", secondPage: []interface{}{map[string]interface{}{"role_id": "role_1", "name": "Target"}}, secondHasMore: true}, + { + name: "partially overlapping final page", + secondPage: []interface{}{ + map[string]interface{}{"role_id": "role_1", "name": "Target"}, + map[string]interface{}{"role_id": "role_2", "name": "Target"}, + }, + secondHasMore: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, reg := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "name": "Target", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"role_id": "role_1", "name": "Target"}}, + "has_more": true, + "total": 3, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "items": tt.secondPage, + "has_more": tt.secondHasMore, + "total": 3, + }, + }, + }) + + err := AppsRoleList.Execute(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Execute() = %#v, want internal/invalid_response", err) + } + if !strings.Contains(problem.Hint, "incomplete exact-name scan") { + t.Fatalf("hint = %q, want incomplete scan guidance", problem.Hint) + } + }) + } +} + +func TestAppsRoleListNameFilterRejectsContradictoryTotal(t *testing.T) { + tests := []struct { + name string + firstTotal int + secondTotal int + secondItems []interface{} + secondHasMore bool + }{ + { + name: "total changes across pages", + firstTotal: 3, + secondTotal: 4, + secondItems: []interface{}{map[string]interface{}{"role_id": "role_2", "name": "Target"}}, + }, + { + name: "final page ends before total", + firstTotal: 3, + secondTotal: 3, + secondItems: []interface{}{map[string]interface{}{"role_id": "role_2", "name": "Target"}}, + }, + { + name: "has more after reaching total", + firstTotal: 2, + secondTotal: 2, + secondItems: []interface{}{map[string]interface{}{"role_id": "role_2", "name": "Target"}}, + secondHasMore: true, + }, + { + name: "items exceed total", + firstTotal: 2, + secondTotal: 2, + secondItems: []interface{}{ + map[string]interface{}{"role_id": "role_2", "name": "Target"}, + map[string]interface{}{"role_id": "role_3", "name": "Target"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rctx, _, reg := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "name": "Target", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"role_id": "role_1", "name": "Target"}}, + "has_more": true, + "total": tt.firstTotal, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "items": tt.secondItems, + "has_more": tt.secondHasMore, + "total": tt.secondTotal, + }, + }, + }) + + err := AppsRoleList.Execute(context.Background(), rctx) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("Execute() = %#v, want internal/invalid_response", err) + } + if !strings.Contains(problem.Hint, "incomplete exact-name scan") { + t.Fatalf("hint = %q, want incomplete scan guidance", problem.Hint) + } + }) + } +} + +func TestAppsRoleDeleteExecutePreservesBackendData(t *testing.T) { + rctx, stdoutBuf, reg := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + "role-id": "role_1", + }) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/spark/v1/apps/app_x/roles/role_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "role_id": "role_1", + "deleted": true, + "audit_id": "audit_123", + }, + }, + }) + + if err := AppsRoleDelete.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + env := decodeRoleEnvelope(t, stdoutBuf.String()) + if env.Data["role_id"] != "role_1" || env.Data["deleted"] != true || env.Data["audit_id"] != "audit_123" { + t.Fatalf("delete output should preserve backend data, got: %#v", env.Data) + } +} + +func TestAppsRoleListPrettySanitizesTerminalContent(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "role_id": "role_1", + "name": "Admin\nFORGED", + "description": "\x1b[31mred\x1b[0m\tvalue", + }, + }, + "has_more": false, + "total": 1, + }, + }, + }) + + if err := runAppsShortcut(t, AppsRoleList, + []string{"+role-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if strings.Contains(got, "\x1b") || strings.Count(got, "\n") != 2 { + t.Fatalf("pretty output contains terminal injection or extra rows: %q", got) + } + for _, want := range []string{"Admin FORGED", "red value"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing sanitized value %q: %q", want, got) + } + } +} + +func TestAppsRoleAPIErrorGetsAppsHint(t *testing.T) { + rctx, _, reg := newRoleRCtx(t, roleCRUDFlagDefs(), map[string]string{ + "app-id": "app_x", + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/roles", + Body: map[string]interface{}{ + "code": 99991663, + "msg": "permission denied", + }, + }) + + err := AppsRoleList.Execute(context.Background(), rctx) + if err == nil { + t.Fatalf("Execute() = nil, want API error") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %#v, want typed problem", err) + } + if problem.Category != errs.CategoryAuthentication || problem.Subtype != errs.SubtypeTokenInvalid || problem.Code != 99991663 { + t.Fatalf("problem = %+v, want authentication/token_invalid code 99991663", problem) + } + if problem.Hint != roleAppHint { + t.Fatalf("hint = %q, want %q", problem.Hint, roleAppHint) + } +} + +type dryRunCall struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + Body interface{} `json:"body"` +} + +type dryRunEnvelope struct { + API []dryRunCall `json:"api"` +} + +func firstDryRunCall(t *testing.T, raw []byte) dryRunCall { + t.Helper() + var env dryRunEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatalf("unmarshal dry-run %s: %v", raw, err) + } + if len(env.API) != 1 { + t.Fatalf("dry-run calls = %d, want 1: %s", len(env.API), raw) + } + return env.API[0] +} + +func mapFromInterface(t *testing.T, value interface{}) map[string]interface{} { + t.Helper() + if value == nil { + return nil + } + m, ok := value.(map[string]interface{}) + if !ok { + t.Fatalf("value = %#v, want map[string]interface{}", value) + } + return m +} + +func assertMapEqual(t *testing.T, got, want map[string]interface{}) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("map = %#v, want %#v", got, want) + } + for k, wantValue := range want { + if got[k] != wantValue { + t.Fatalf("map[%s] = %#v, want %#v; full map=%#v", k, got[k], wantValue, got) + } + } +} + +type roleEnvelope struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` +} + +func decodeRoleEnvelope(t *testing.T, raw string) roleEnvelope { + t.Helper() + var env roleEnvelope + if err := json.Unmarshal([]byte(raw), &env); err != nil { + t.Fatalf("unmarshal output: %v\nraw: %s", err, raw) + } + if !env.OK { + t.Fatalf("ok = false, raw: %s", raw) + } + return env +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index bc08ffb1c2..d9da248952 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -17,6 +17,15 @@ func Shortcuts() []common.Shortcut { AppsList, AppsAccessScopeSet, AppsAccessScopeGet, + AppsRoleList, + AppsRoleGet, + AppsRoleCreate, + AppsRoleUpdate, + AppsRoleDelete, + AppsRoleMemberList, + AppsRoleMemberAdd, + AppsRoleMemberRemove, + AppsRoleMatchList, AppsHTMLPublish, AppsInit, AppsReleaseCreate, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 540bfc89af..ba3be67f53 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -21,11 +21,12 @@ import ( // - 5 session(create/list/get/stop/chat)+ 1 session-messages-list // - 8 openapi-key(list/get/create/update/enable/disable/delete/reset) // - 3 plugin(install/uninstall/list) -// - 6 automation(list/get/create/update/enable/disable)= 70。 -func TestAppsShortcuts_Returns70(t *testing.T) { +// - 6 automation(list/get/create/update/enable/disable) +// - 9 role(role CRUD + role-member list/add/remove + role-match-list)= 79。 +func TestAppsShortcuts_Returns79(t *testing.T) { got := Shortcuts() - if len(got) != 70 { - t.Fatalf("Shortcuts() returned %d entries, want 70", len(got)) + if len(got) != 79 { + t.Fatalf("Shortcuts() returned %d entries, want 79", len(got)) } } @@ -89,6 +90,34 @@ func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) { } } +// 确认 role 管理命令都已挂载,避免实现存在但 shortcut 漏注册。 +func TestAppsShortcuts_IncludesRoleCommands(t *testing.T) { + want := map[string]bool{ + "+role-list": false, + "+role-get": false, + "+role-create": false, + "+role-update": false, + "+role-delete": false, + "+role-member-list": false, + "+role-member-add": false, + "+role-member-remove": false, + "+role-match-list": false, + } + for _, sc := range Shortcuts() { + if _, ok := want[sc.Command]; ok { + want[sc.Command] = true + if sc.Hidden { + t.Errorf("%s must be visible", sc.Command) + } + } + } + for cmd, found := range want { + if !found { + t.Errorf("Shortcuts() missing %s", cmd) + } + } +} + // TestAppsGitCredentialHelper_IsNotAShortcut 确认 git credential helper 不作为 shortcut 暴露。 func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) { for _, shortcut := range Shortcuts() { diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index ce4b4d152d..e4793fa8d7 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)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理、自动化触发器(定时/记录变更/Webhook/飞书审批)。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量、给妙搭应用配自动化任务/定时触发/审批通过后自动触发时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" +description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理、应用角色与成员管理、自动化触发器(定时/记录变更/Webhook/飞书审批)。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、应用角色/角色成员、线上日志、接口请求量、错误量、延迟、访问量、环境变量、给妙搭应用配自动化任务/定时触发/审批通过后自动触发时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" metadata: requires: bins: ["lark-cli"] @@ -12,15 +12,15 @@ metadata: 妙搭应用属于用户资产。默认用 `--as user`;认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。 -## 身份与一次性授权 +## 身份与授权 -妙搭应用是用户的个人资产,统一 `--as user`(见开头)。**首次操作前先一次性把本域 scope 全拿到**,避免每条命令首次跑都触发新一轮授权,或未授权直接打到 openapi 导致服务端报错: +妙搭应用是用户的个人资产,统一 `--as user`(见开头)。已有用户身份可用时直接执行业务命令,**不要为了预防权限问题主动重新登录**,否则可能中断原任务并触发不必要的设备授权。仅当 CLI 明确返回未登录或缺少本域 scope 时,一次性执行: ```bash lark-cli auth login --domain apps ``` -因缺权限失败(`error.subtype == "missing_scope"`)时的通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),同样按 `--domain apps` 授权。 +因缺权限失败(`error.subtype == "missing_scope"`)时的通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),同样按 `--domain apps` 授权;授权成功后只恢复原业务操作,不扩展任务范围。 ## 意图路由 @@ -33,7 +33,7 @@ lark-cli auth login --domain apps | 查单个应用详情(类型、名称、发布状态等) | `+get --app-id ` | [`lark-apps-get.md`](references/lark-apps-get.md) | | 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) | | 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) | -| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git)。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md),含端到端流程和领域规则 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | +| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git)。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md);修改源码还须遵守下方「平台资源与应用源码边界」 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | | 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) | | 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-list`, `+analytics-list` | [`lark-apps-observability.md`](references/lark-apps-observability.md) | @@ -42,6 +42,7 @@ lark-cli auth login --domain apps | 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) | | **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`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 | +| 管理 `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) | | 管理妙搭应用开放 API Key(创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) | | 管理妙搭应用自动化触发器(定时/记录变更/Webhook/飞书审批四类触发器的查询/创建/更新/启停;Webhook URL·Token 一次性回显、不落盘) | `+automation-list/get/create/update/enable/disable` | [`lark-apps-automation.md`](references/lark-apps-automation.md) | @@ -78,10 +79,15 @@ lark-cli auth login --domain apps - 发布态链接来源:html → `+html-publish` 的 `data.url`;全栈 → `+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`。 - **可见范围**:发布态链接(html 的 `data.url`、全栈的 `online_url`)默认仅**创建者可见**,发给他人对方会无权限打不开。当可分享链接交付给用户前,先告知当前仅本人可见,再询问是否用 `+access-scope-set`(`tenant`/`public`/`specific`)放开(可先 `+access-scope-get` 查当前范围)。 -## 能力边界 +## 平台资源与应用源码边界 -- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。 -- 用户要配置权限时,引导其使用开发态链接前往云端开发(妙搭 web)处理。自动化触发器请用 `+automation-*`(见「意图路由」)。 +- `apps +role-*` 只管理平台角色资源;修改已初始化应用的源码(包括当前目录已经是应用项目)时,先查看工作区 `.agents/skills/`,完整读取与任务匹配的领域 skill,再按其路由读取所需 reference。角色鉴权或运行态角色管理读应用内 `authz-guide`,不能用本 skill 的平台命令参考推断运行时合同。 +- `lark-cli` 只用于开发过程中的平台资源核验或变更。应用运行时代码必须使用工程内领域 skill 规定的 SDK,禁止通过 `exec` 或子进程调用 `lark-cli`。 +- 平台回读出的当前资源 ID、名称和成员只用于事实核验,不自动构成业务策略;除非需求或应用内领域 skill 明确定义,禁止把当前样本硬编码成 allowlist、denylist、只读集合或权限规则。 +- 实现领域 SDK 时,以实际包导出的类型和应用内领域 reference 记录的入参、响应路径为准;禁止修改 ambient `.d.ts`、补造宽松类型或强制断言,让猜测的 SDK 结构仅在本地“编译通过”。 +- typecheck/build 成功不等于合同正确。交付前逐项核对每个 SDK 调用的入参、响应取值路径和策略分支;涉及更新、删除等不同动作时,分别验证各自动作所需的完整状态,不能复用更弱的前置判断。 +- 源码任务交付前确认新增页面、Controller、Module 已接入真实 router/bootstrap,并运行项目现有 typecheck/build;只创建未接线文件不算完成。 +- `+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限;应用协作者/开发权限仍需使用妙搭 Web。自动化触发器请用 `+automation-*`(见「意图路由」)。 ## app_id 获取 @@ -101,4 +107,4 @@ lark-cli auth login --domain apps ## 高影响动作:确认与预授权 - **预授权判定**:判断用户是否表达了"放手做完、不用中途逐步问我"的意图——明确免确认(如"别问 / 直接做 / 自己定"),或要求一气呵成做到完成(如"做完部署上线给我")。是 → 整个流程按合理默认往下走、不再逐步确认(含 clone 到派生目录、发布等);否 → 缺失参数(如目录)该问就问、高影响动作先确认。 -- **禁止预授权判定底线**(即便已预授权也不豁免):① 会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))先 `--dry-run` 确认;② `+html-publish` 体积超限时(判据见 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)),立即停止并转述超限项。 +- **禁止预授权判定底线**(即便已预授权也不豁免):① 会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))先 `--dry-run` 确认;② `+role-delete`、`+role-member-remove --all`、批量移除成员必须先确认 app、role、成员范围和后果,不能从泛化"直接做"推导出 `--yes`;命令式“删除/移除某对象”只确定操作目标,不等于用户已确认不可逆后果,未明确确认时应在说明影响后停下请求确认;③ `+html-publish` 体积超限时(判据见 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)),立即停止并转述超限项。 diff --git a/skills/lark-apps/references/lark-apps-access-scope-set.md b/skills/lark-apps/references/lark-apps-access-scope-set.md index bd70f78c0c..3a662233a0 100644 --- a/skills/lark-apps/references/lark-apps-access-scope-set.md +++ b/skills/lark-apps/references/lark-apps-access-scope-set.md @@ -37,4 +37,4 @@ lark-cli apps +access-scope-set --app-id app_xxx --scope specific \ 若服务端返回"应用未发布/需先发布才能设置可见范围",把这一情况转述给用户并询问是否现在发布,得到同意后再 `+release-create`,不要把这个 hint 当指令自动发布。 -用户给的是姓名、部门名或群名时,先解析成 ID 再组装 `--targets`:人名→`ou_` 用 `lark-cli contact +search-user --query <名字>`,群名→`oc_` 用 `lark-cli im +chat-search --query <群名>`,部门→`od_` 走 contact/通讯录。多候选时展示名称和 ID 让用户选,不要要求用户手填 `ou_` / `od_` / `oc_`。 +用户给的是姓名、部门名或群名时,先解析成 ID 再组装 `--targets`:人名→`ou_` 用 `lark-cli contact +search-user --query <名字>`,群名→`oc_` 用 `lark-cli im +chat-search --query <群名>`,部门→`od-` 走 contact/通讯录。多候选时展示名称和 ID 让用户选,不要要求用户手填 `ou_` / `od-` / `oc_`。 diff --git a/skills/lark-apps/references/lark-apps-role.md b/skills/lark-apps/references/lark-apps-role.md new file mode 100644 index 0000000000..9365193d43 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-role.md @@ -0,0 +1,133 @@ +# apps role 域命令(应用角色) + +管理妙搭应用内的平台角色、角色成员,以及查询某个用户命中的角色。运行时命令事实以 `lark-cli apps + --help` 为准;身份、授权和高风险确认遵循本域 [`SKILL.md`](../SKILL.md)。 + +## 何时用 + +用户要列出、查看、创建、更新或删除某个妙搭应用内的平台角色,管理角色的用户、部门或群成员,或查询某个用户在应用中命中的角色时使用。多维表格 / Base 的角色与权限走 `lark-base`;设置谁能访问应用走 `+access-scope-*`,不要路由到本命令域。 + +## 命令一览 + +| 命令 | 做什么 | 关键参数 | +|---|---|---| +| `+role-list` | 分页列出角色,或按名称筛选角色 | `--app-id`、`--name`、`--page-size`/`--page-token` | +| `+role-get` | 根据真实 `role_id` 读取角色详情 | `--app-id`、`--role-id` | +| `+role-match-list` | 查询指定用户命中的角色 | `--app-id`、`--user-id` | +| `+role-create` | 创建角色 | `--app-id`、`--name`、`--description`、`--role-id` | +| `+role-update` | 更新角色名称或描述 | `--app-id`、`--role-id`、`--name`/`--description` | +| `+role-delete` | 永久删除角色 | `--app-id`、`--role-id`、`--yes` | +| `+role-member-list` | 查询角色的用户、部门和群成员 | `--app-id`、`--role-id`、`--member-type` | +| `+role-member-add` | 向角色添加用户、部门或群成员 | `--app-id`、`--role-id`、`--users`/`--departments`/`--chats` | +| `+role-member-remove` | 定向移除或清空角色成员 | `--app-id`、`--role-id`、成员参数或 `--all`、`--yes` | + +## 约定(先读) + +- `app_...` 标识的是妙搭应用,其角色和成员只使用 `apps +role-*` / `apps +role-member-*`;不要改走 Base 角色命令或裸 bitable API。 +- 角色名称不是 `role_id`。只有名称时优先用 `+role-list --name` 精确解析;若已取得完整分页列表,也可从中证明精确名称唯一命中。0 条如实报告,多条让用户消歧,唯一命中后才使用返回的真实 ID。 +- `+role-list` 返回 `has_more=true` 时,用本页 `page_token` 继续查询,直到 `has_more=false`;不要根据 `total` 补造条目。 +- `+role-list`、`+role-get`、`+role-match-list` 的角色数据分别位于 `data.items`、`data.role`、`data.roles`,不要混用。 +- 同一角色的写入及依赖该写入结果的操作必须串行。不同角色的独立操作只有在每次写入可单独追溯、失败不影响其它目标且分别验收时才可并行;否则保持串行。互不依赖的名称解析或只读查询可并行。 + +## 各命令 + +### 查询角色 + +```bash +lark-cli apps +role-list --app-id --page-size 100 +lark-cli apps +role-list --app-id --name '' +lark-cli apps +role-get --app-id --role-id +lark-cli apps +role-match-list --app-id --user-id +``` + +整理角色列表时保留 `role_id`、`name` 和 `description`。不要猜测未知 `role_id`,也不要从同名候选中静默选择。 +`items=[]` 时直接报告当前没有角色;不要为表格补造“无”或 `N/A` 占位行。 +`+role-match-list --user-id` 只接受 `ou_...`;用户给的是姓名、邮箱或手机号时,先解析唯一 open ID,再查询命中角色。 + +### 创建与更新 + +```bash +lark-cli apps +role-create --app-id --name '' \ + --description '' + +# 只修改名称 +lark-cli apps +role-update --app-id --role-id \ + --name '' --as user --format json + +# 只修改描述 +lark-cli apps +role-update --app-id --role-id \ + --description '' --as user --format json +``` + +- `--description` 和创建时的 `--role-id` 可选;仅在确实需要稳定 ID 时传 `--role-id`,创建后不能修改。 +- 更新时只传用户明确要求变更的字段。 +- 成功响应中的角色位于 `data.role`。只有用户要求独立验证,或结果将用于后续高风险操作时,才额外执行 `+role-get`。 + +### 删除角色 + +普通“删除某角色”请求只说明目标,**不等于不可逆确认**。如果用户尚未明确确认删除后果,本轮只能定位角色、读取完整成员并说明影响,最后请求确认;不得在同一轮自动追加 `--yes`。用户已明确确认不可逆删除时才继续。 + +只有名称时仍按上述规则唯一解析,优先使用 `+role-list --name`。目标写前已不存在时立即停止,如实说明本次是 no-op、没有执行删除,不能把“当前不存在”表述为“删除成功”。 + +删除前读取准确角色和完整成员范围,向用户说明 app、role、`users` / `departments` / `chats` 影响;得到不可逆删除确认后才使用 `--yes`: + +```bash +lark-cli apps +role-get --app-id --role-id +lark-cli apps +role-member-list --app-id --role-id +lark-cli apps +role-delete --app-id --role-id --yes +``` + +成功响应包含匹配的 `data.role_id` 和 `data.deleted=true`。只有用户明确要求独立验证删除结果时,才再用 `+role-list --name` 检查目标 ID 已不存在。 + +### 成员 ID 解析 + +成员 flags 只接受 open ID:用户 `ou_...`、部门 `od-...`、群 `oc_...`。用户已提供对应类型的合法 open ID 时直接使用;只有名称或邮箱时才解析。 +对象类型以用户语义为准,不能互换解析器:用户走通讯录用户搜索,部门走部门搜索,群走群搜索。 + +```bash +# 用户:每个姓名或邮箱单独查询。 +lark-cli contact +search-user --query '<姓名或邮箱>' \ + --exclude-external-users --page-size 30 + +# 部门:拉完分页,只接受唯一的 open_department_id。 +lark-cli api POST /open-apis/contact/v3/departments/search \ + --params '{"user_id_type":"open_id","department_id_type":"open_department_id","page_size":50}' \ + --data '{"query":"<部门名称>"}' + +# 群:拉完分页,只接受名称精确匹配的唯一 chat_id。 +lark-cli im +chat-search --query '<群名称>' --page-size 50 +``` + +- 只接受与输入姓名、邮箱或群名精确匹配的唯一结果;部门搜索只接受完整 query 的唯一 `od-...`。0 条、多条或分页未完成时停止写入并让用户补充或消歧。 +- 多个对象逐个解析。全部解析成功且总数不超过 100 后,按类型放入一次成员写入;任一对象失败时不要部分写入,也不要自动拆批。 + +### 成员操作 + +```bash +# 省略 --member-type,返回完整 users / departments / chats。 +lark-cli apps +role-member-list --app-id --role-id + +lark-cli apps +role-member-add --app-id --role-id \ + --users ou_x,ou_y --departments od-x --chats oc_x + +lark-cli apps +role-member-remove --app-id --role-id \ + --users ou_x --yes + +# 清空成员,不删除角色。 +lark-cli apps +role-member-remove --app-id --role-id \ + --all --yes +``` + +- `+role-member-list` 不分页;`--member-type` 只返回选中类型的字段,未返回的成员字段表示“未查询”而不是空。影响确认或完整比较时必须省略它。 +- 汇总 `--member-type` 结果时明确这是过滤投影,不得据此断言角色没有其它类型成员。 +- 用户要求 CLI 原生 table 时,直接执行 `+role-member-list --format table`;可原样转发或做事实摘要,不要先取 JSON 再手工重建一张替代表格。 +- 写入和依赖其结果的回读不得放进同一个并发批次;必须等待写入完整返回成功后,再单独发起回读。误并发时只能以写入完成后的新回读作为结果证据。 +- 添加前仅在用户要求独立证明或确认其他成员类型未变化时读取完整基线,并在写后完整回读;否则成功响应即可作为结果。 +- 定向移除前确认准确成员及影响。若需要证明结果,写后完整回读;不要把过滤结果当作完整成员集合。 +- `--all` 前读取完整成员范围并确认;成功后执行一次无过滤 `+role-member-list`,确认三个成员数组均为空。 + +## 权限 + +| 操作 | 所需 scope | +|---|---| +| list / get / member-list / match-list | `spark:app:read` | +| create / update / delete / member-add / member-remove | `spark:app:write` | diff --git a/tests/cli_e2e/apps/apps_role_management_test.go b/tests/cli_e2e/apps/apps_role_management_test.go new file mode 100644 index 0000000000..5b59581d50 --- /dev/null +++ b/tests/cli_e2e/apps/apps_role_management_test.go @@ -0,0 +1,789 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "os" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestAppsRoleManagementDryRun_RequestShapes(t *testing.T) { + setAppsRoleDryRunEnv(t) + + tests := []struct { + name string + args []string + wantMethod string + wantURL string + assertShape func(t *testing.T, stdout string) + }{ + { + name: "RoleList_NameFilterShowsFirstAutomaticScanRequest", + args: []string{"apps", "+role-list", "--app-id", "app_role_e2e", "--name", "admin", "--page-size", "20", "--page-token", "40", "--dry-run"}, + wantMethod: "GET", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles", + assertShape: func(t *testing.T, stdout string) { + assert.Equal(t, "admin", gjson.Get(stdout, "api.0.params.name").String(), "stdout:\n%s", stdout) + assert.Equal(t, int64(100), gjson.Get(stdout, "api.0.params.limit").Int(), "name scan uses maximum backend page size; stdout:\n%s", stdout) + assert.Equal(t, int64(0), gjson.Get(stdout, "api.0.params.offset").Int(), "name scan starts from the first backend page; stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.params.page_size").Exists(), "CLI page-size must be mapped to backend limit; stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.params.page_token").Exists(), "CLI page-token must be mapped to backend offset; stdout:\n%s", stdout) + }, + }, + { + name: "RoleList_DefaultsPageSizeTo20", + args: []string{"apps", "+role-list", "--app-id", "app_role_e2e", "--dry-run"}, + wantMethod: "GET", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles", + assertShape: func(t *testing.T, stdout string) { + assert.Equal(t, int64(20), gjson.Get(stdout, "api.0.params.limit").Int(), "stdout:\n%s", stdout) + assert.Equal(t, int64(0), gjson.Get(stdout, "api.0.params.offset").Int(), "stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.params.page_size").Exists(), "CLI page-size must be mapped to backend limit; stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.params.page_token").Exists(), "CLI page-token must be mapped to backend offset; stdout:\n%s", stdout) + }, + }, + { + name: "RoleGet_UsesRolePathSegment", + args: []string{"apps", "+role-get", "--app-id", "app_role_e2e", "--role-id", "role_admin", "--dry-run"}, + wantMethod: "GET", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_admin", + assertShape: func(t *testing.T, stdout string) { + assert.False(t, gjson.Get(stdout, "api.0.params").Exists(), "role-get should not send query params; stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body").Exists(), "role-get should not send body; stdout:\n%s", stdout) + }, + }, + { + name: "RoleCreate_WithExplicitRoleID", + args: []string{"apps", "+role-create", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--name", "Data Analyst", "--description", "Can inspect BI reports", "--dry-run"}, + wantMethod: "POST", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles", + assertShape: func(t *testing.T, stdout string) { + assert.Equal(t, "role_analyst", gjson.Get(stdout, "api.0.body.role_id").String(), "stdout:\n%s", stdout) + assert.Equal(t, "Data Analyst", gjson.Get(stdout, "api.0.body.name").String(), "stdout:\n%s", stdout) + assert.Equal(t, "Can inspect BI reports", gjson.Get(stdout, "api.0.body.description").String(), "stdout:\n%s", stdout) + }, + }, + { + name: "RoleCreate_OmitsRoleIDWhenServerGeneratesIt", + args: []string{"apps", "+role-create", "--app-id", "app_role_e2e", "--name", "Generated Role", "--dry-run"}, + wantMethod: "POST", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles", + assertShape: func(t *testing.T, stdout string) { + assert.Equal(t, "Generated Role", gjson.Get(stdout, "api.0.body.name").String(), "stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.role_id").Exists(), "role_id should be omitted unless explicitly provided; stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.description").Exists(), "description should be omitted unless explicitly provided; stdout:\n%s", stdout) + }, + }, + { + name: "RoleCreate_PreservesExplicitEmptyDescription", + args: []string{"apps", "+role-create", "--app-id", "app_role_e2e", "--name", "Empty Description", "--description", "", "--dry-run"}, + wantMethod: "POST", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles", + assertShape: func(t *testing.T, stdout string) { + assert.Equal(t, "Empty Description", gjson.Get(stdout, "api.0.body.name").String(), "stdout:\n%s", stdout) + assert.True(t, gjson.Get(stdout, "api.0.body.description").Exists(), "description should be present when explicitly set empty; stdout:\n%s", stdout) + assert.Equal(t, "", gjson.Get(stdout, "api.0.body.description").String(), "stdout:\n%s", stdout) + }, + }, + { + name: "RoleUpdate_SendsOnlyExplicitFields", + args: []string{"apps", "+role-update", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--description", "Updated description", "--dry-run"}, + wantMethod: "PATCH", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst", + assertShape: func(t *testing.T, stdout string) { + assert.Equal(t, "Updated description", gjson.Get(stdout, "api.0.body.description").String(), "stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.name").Exists(), "name should be omitted when not explicitly provided; stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.role_id").Exists(), "role_id is immutable and must not be sent in update body; stdout:\n%s", stdout) + }, + }, + { + name: "RoleUpdate_PreservesExplicitEmptyDescription", + args: []string{"apps", "+role-update", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--description", "", "--dry-run"}, + wantMethod: "PATCH", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst", + assertShape: func(t *testing.T, stdout string) { + assert.True(t, gjson.Get(stdout, "api.0.body.description").Exists(), "description should be present when explicitly set empty; stdout:\n%s", stdout) + assert.Equal(t, "", gjson.Get(stdout, "api.0.body.description").String(), "stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.name").Exists(), "name should be omitted when not explicitly provided; stdout:\n%s", stdout) + }, + }, + { + name: "RoleDelete_RequiresHighRiskConfirmationAndUsesDelete", + args: []string{"apps", "+role-delete", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--yes", "--dry-run"}, + wantMethod: "DELETE", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst", + assertShape: func(t *testing.T, stdout string) { + assert.False(t, gjson.Get(stdout, "api.0.body").Exists(), "role-delete should not send a request body; stdout:\n%s", stdout) + }, + }, + { + name: "RoleMemberList_UsesMemberTypeOnly", + args: []string{"apps", "+role-member-list", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--member-type", "user", "--dry-run"}, + wantMethod: "GET", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst/member_list", + assertShape: func(t *testing.T, stdout string) { + assert.Equal(t, "user", gjson.Get(stdout, "api.0.params.member_type").String(), "stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.params.limit").Exists(), "member-list no longer sends pagination; stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.params.offset").Exists(), "member-list no longer sends pagination; stdout:\n%s", stdout) + }, + }, + { + name: "RoleMemberAdd_MapsUsersDepartmentsChatsToBackendTypes", + args: []string{"apps", "+role-member-add", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--users", "ou_a,ou_b", "--departments", "od-a", "--chats", "oc_a", "--dry-run"}, + wantMethod: "POST", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst/member_add", + assertShape: func(t *testing.T, stdout string) { + assert.Equal(t, "ou_a", gjson.Get(stdout, "api.0.body.users.0").String(), "stdout:\n%s", stdout) + assert.Equal(t, "ou_b", gjson.Get(stdout, "api.0.body.users.1").String(), "stdout:\n%s", stdout) + assert.Equal(t, "od-a", gjson.Get(stdout, "api.0.body.departments.0").String(), "stdout:\n%s", stdout) + assert.Equal(t, "oc_a", gjson.Get(stdout, "api.0.body.chats.0").String(), "stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.members").Exists(), "API contract uses users/departments/chats arrays; stdout:\n%s", stdout) + }, + }, + { + name: "RoleMemberRemove_UsesGroupedBody", + args: []string{"apps", "+role-member-remove", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--users", "ou_a", "--yes", "--dry-run"}, + wantMethod: "POST", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst/member_remove", + assertShape: func(t *testing.T, stdout string) { + assert.Equal(t, "ou_a", gjson.Get(stdout, "api.0.body.users.0").String(), "stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.members").Exists(), "API contract uses users/departments/chats arrays; stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.all").Exists(), "all should be omitted when removing explicit members; stdout:\n%s", stdout) + }, + }, + { + name: "RoleMemberRemoveAll_SendsOnlyAllTrue", + args: []string{"apps", "+role-member-remove", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--all", "--yes", "--dry-run"}, + wantMethod: "POST", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst/member_remove", + assertShape: func(t *testing.T, stdout string) { + assert.True(t, gjson.Get(stdout, "api.0.body.all").Bool(), "stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.members").Exists(), "--all body must not include explicit members; stdout:\n%s", stdout) + }, + }, + { + name: "RoleMatchList_UsesFullQueryEndpointWithoutRoleID", + args: []string{"apps", "+role-match-list", "--app-id", "app_role_e2e", "--user-id", "ou_target", "--dry-run"}, + wantMethod: "POST", + wantURL: "/open-apis/spark/v1/apps/app_role_e2e/user_role_list", + assertShape: func(t *testing.T, stdout string) { + assert.Equal(t, "ou_target", gjson.Get(stdout, "api.0.body.target_user_id").String(), "stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.role_id").Exists(), "role-match-list must not require role_id; stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.params").Exists(), "role-match-list must use body instead of query params; stdout:\n%s", stdout) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tc.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + dryRunData := gjson.Get(result.Stdout, "data").Raw + require.NotEmpty(t, dryRunData, "dry-run output must expose request data under data; stdout:\n%s", result.Stdout) + assert.Equal(t, tc.wantMethod, gjson.Get(dryRunData, "api.0.method").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, tc.wantURL, gjson.Get(dryRunData, "api.0.url").String(), "stdout:\n%s", result.Stdout) + tc.assertShape(t, dryRunData) + }) + } +} + +func TestAppsRoleManagementValidation(t *testing.T) { + setAppsRoleDryRunEnv(t) + + tests := []struct { + name string + args []string + wantParam string + wantParams []string + wantMessage string + wantHint string + }{ + { + name: "RejectsCreateWithoutNameWithStructuredParam", + args: []string{"apps", "+role-create", "--app-id", "app_role_e2e", "--description", "Can inspect BI reports", "--dry-run"}, + wantParam: "--name", + wantMessage: "--name is required", + wantHint: "do not infer a name", + }, + { + name: "RejectsInvalidRoleID", + args: []string{"apps", "+role-create", "--app-id", "app_role_e2e", "--role-id", "../bad", "--name", "Bad", "--dry-run"}, + wantParam: "--role-id", + wantMessage: "--role-id must match [A-Za-z0-9_-]{1,64}", + }, + { + name: "RejectsLarkCredentialAppID", + args: []string{"apps", "+role-list", "--app-id", "cli_role_e2e", "--dry-run"}, + wantParam: "--app-id", + wantMessage: "Miaoda app_id", + }, + { + name: "RejectsAppIDWithoutMiaodaPrefix", + args: []string{"apps", "+role-list", "--app-id", "plain_role_e2e", "--dry-run"}, + wantParam: "--app-id", + wantMessage: "starting with app_", + }, + { + name: "RejectsExplicitEmptyRoleListName", + args: []string{"apps", "+role-list", "--app-id", "app_role_e2e", "--name", "", "--dry-run"}, + wantParam: "--name", + wantMessage: "--name must not be empty when provided", + }, + { + name: "RejectsPageSizeAboveLimit", + args: []string{"apps", "+role-list", "--app-id", "app_role_e2e", "--page-size", "101", "--dry-run"}, + wantParam: "--page-size", + wantMessage: "--page-size must be between 1 and 100", + }, + { + name: "RejectsNonNumericPageToken", + args: []string{"apps", "+role-list", "--app-id", "app_role_e2e", "--page-token", "next", "--dry-run"}, + wantParam: "--page-token", + wantMessage: "--page-token", + }, + { + name: "RejectsUpdateWithoutPatchFields", + args: []string{"apps", "+role-update", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--dry-run"}, + wantParams: []string{"--name", "--description"}, + wantMessage: "--name or --description is required", + }, + { + name: "RejectsMemberAddWithoutMembers", + args: []string{"apps", "+role-member-add", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--dry-run"}, + wantParams: []string{"--users", "--departments", "--chats"}, + wantMessage: "at least one of --users, --departments, or --chats is required", + }, + { + name: "RejectsMoreThan100MembersBeforeDryRun", + args: []string{"apps", "+role-member-add", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--users", strings.TrimSuffix(strings.Repeat("ou_a,", 101), ","), "--dry-run"}, + wantParams: []string{"--users"}, + wantMessage: "role members cannot exceed 100", + }, + { + name: "RejectsRemoveAllWithExplicitMembers", + args: []string{"apps", "+role-member-remove", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--all", "--users", "ou_a", "--yes", "--dry-run"}, + wantParams: []string{"--all", "--users"}, + wantMessage: "--all", + }, + { + name: "RejectsRemoveWithoutMembersOrAll", + args: []string{"apps", "+role-member-remove", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--yes", "--dry-run"}, + wantParams: []string{"--users", "--departments", "--chats", "--all"}, + wantMessage: "specify members to remove", + }, + { + name: "RejectsBlankUserIDForMatchList", + args: []string{"apps", "+role-match-list", "--app-id", "app_role_e2e", "--user-id", " ", "--dry-run"}, + wantParam: "--user-id", + wantMessage: "user-id", + }, + { + name: "RejectsEmailUserIDForMatchList", + args: []string{"apps", "+role-match-list", "--app-id", "app_role_e2e", "--user-id", "alice@example.com", "--dry-run"}, + wantParam: "--user-id", + wantMessage: "ou_", + }, + { + name: "RejectsWrongDepartmentIDPrefix", + args: []string{"apps", "+role-member-add", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--departments", "ou_user", "--dry-run"}, + wantParam: "--departments", + wantMessage: "od-", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tc.args, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + envelope := validationEnvelope(result) + assert.Equal(t, "validation", gjson.Get(envelope, "error.type").String(), "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + assert.Equal(t, "invalid_argument", gjson.Get(envelope, "error.subtype").String(), "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + if len(tc.wantParams) > 0 { + assert.False(t, gjson.Get(envelope, "error.param").Exists(), "scalar param must be omitted: %s", envelope) + gotParams := make([]string, 0, len(tc.wantParams)) + for _, param := range gjson.Get(envelope, "error.params.#.name").Array() { + gotParams = append(gotParams, param.String()) + } + assert.Equal(t, tc.wantParams, gotParams, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } else { + assert.Equal(t, tc.wantParam, gjson.Get(envelope, "error.param").String(), "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } + assert.Contains(t, envelope, tc.wantMessage, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + if tc.wantHint != "" { + assert.Contains(t, gjson.Get(envelope, "error.hint").String(), tc.wantHint, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } + }) + } +} + +func TestAppsRoleManagementLiveWorkflow(t *testing.T) { + requireLiveRoleFixture(t) + + appID := os.Getenv("LARK_CLI_E2E_APPS_ROLE_APP_ID") + roleID := liveAppsRoleFixtureID() + member := requireLiveRoleMemberFixture(t) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + baselineResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"apps", "+role-member-list", "--app-id", appID, "--role-id", roleID, "--member-type", member.memberType}, + DefaultAs: "user", + }, clie2e.RetryOptions{}) + require.NoError(t, err) + baselineResult.AssertExitCode(t, 0) + baselineResult.AssertStdoutStatus(t, true) + if jsonStringArrayContains(baselineResult.Stdout, member.dataPath, member.id) { + t.Skipf("FIXTURE: member %s already belongs to role %s; refusing to mutate pre-existing state", member.id, roleID) + } + needsMemberCleanup := false + t.Cleanup(func() { + if !needsMemberCleanup { + return + } + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + removeResult, removeErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"apps", "+role-member-remove", "--app-id", appID, "--role-id", roleID, member.flag, member.id}, + DefaultAs: "user", + Yes: true, + }) + clie2e.ReportCleanupFailure(t, "remove added apps role member "+member.id, removeResult, removeErr) + }) + + t.Run("read fixture role", func(t *testing.T) { + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"apps", "+role-get", "--app-id", appID, "--role-id", roleID}, + DefaultAs: "user", + }, clie2e.RetryOptions{}) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Equal(t, roleID, gjson.Get(result.Stdout, "data.role.role_id").String(), "stdout:\n%s", result.Stdout) + }) + + t.Run("add list and remove fixture member", func(t *testing.T) { + // Arm cleanup before the write so a transport failure after a committed + // request cannot leak the member into the shared fixture role. + needsMemberCleanup = true + addResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+role-member-add", "--app-id", appID, "--role-id", roleID, member.flag, member.id}, + DefaultAs: "user", + }) + require.NoError(t, err) + addResult.AssertExitCode(t, 0) + addResult.AssertStdoutStatus(t, true) + assert.True(t, jsonStringArrayContains(addResult.Stdout, member.dataPath, member.id), "stdout:\n%s", addResult.Stdout) + + listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"apps", "+role-member-list", "--app-id", appID, "--role-id", roleID, "--member-type", member.memberType}, + DefaultAs: "user", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + if result == nil || result.ExitCode != 0 { + return true + } + return !jsonStringArrayContains(result.Stdout, member.dataPath, member.id) + }, + }) + require.NoError(t, err) + listResult.AssertExitCode(t, 0) + listResult.AssertStdoutStatus(t, true) + assert.True(t, jsonStringArrayContains(listResult.Stdout, member.dataPath, member.id), "stdout:\n%s", listResult.Stdout) + + removeResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+role-member-remove", "--app-id", appID, "--role-id", roleID, member.flag, member.id}, + DefaultAs: "user", + Yes: true, + }) + require.NoError(t, err) + removeResult.AssertExitCode(t, 0) + removeResult.AssertStdoutStatus(t, true) + assert.True(t, jsonStringArrayContains(removeResult.Stdout, member.dataPath, member.id), "stdout:\n%s", removeResult.Stdout) + + removedReadback, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"apps", "+role-member-list", "--app-id", appID, "--role-id", roleID, "--member-type", member.memberType}, + DefaultAs: "user", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + return result == nil || result.ExitCode != 0 || jsonStringArrayContains(result.Stdout, member.dataPath, member.id) + }, + }) + require.NoError(t, err) + removedReadback.AssertExitCode(t, 0) + removedReadback.AssertStdoutStatus(t, true) + require.False(t, jsonStringArrayContains(removedReadback.Stdout, member.dataPath, member.id), "stdout:\n%s", removedReadback.Stdout) + needsMemberCleanup = false + }) +} + +func TestAppsRoleLifecycleLiveWorkflow(t *testing.T) { + requireLiveRoleLifecycleFixture(t) + + appID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_APP_ID")) + member := requireLiveRoleMemberFixture(t) + suffix := strings.ReplaceAll(clie2e.GenerateSuffix(), "-", "_") + roleID := "role_e2e_" + suffix + roleName := "CLI Role E2E " + suffix + createdDescription := "created by role lifecycle e2e" + updatedDescription := "updated by role lifecycle e2e" + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + roleMayExist := true + t.Cleanup(func() { + if !roleMayExist { + return + } + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + + listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"apps", "+role-list", "--app-id", appID, "--name", roleName}, + DefaultAs: "user", + }) + if listErr != nil || listResult == nil || listResult.ExitCode != 0 { + clie2e.ReportCleanupFailure(t, "locate transient apps role "+roleID, listResult, listErr) + return + } + if !roleListContainsID(listResult.Stdout, roleID) { + return + } + + clearResult, clearErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"apps", "+role-member-remove", "--app-id", appID, "--role-id", roleID, "--all"}, + DefaultAs: "user", + Yes: true, + }) + clie2e.ReportCleanupFailure(t, "clear transient apps role members "+roleID, clearResult, clearErr) + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"apps", "+role-delete", "--app-id", appID, "--role-id", roleID}, + DefaultAs: "user", + Yes: true, + }) + clie2e.ReportCleanupFailure(t, "delete transient apps role "+roleID, deleteResult, deleteErr) + }) + + createResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+role-create", "--app-id", appID, "--role-id", roleID, + "--name", roleName, "--description", createdDescription, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + createResult.AssertExitCode(t, 0) + createResult.AssertStdoutStatus(t, true) + assert.Equal(t, roleID, gjson.Get(createResult.Stdout, "data.role.role_id").String(), "stdout:\n%s", createResult.Stdout) + + createdReadback := readRoleUntil(t, ctx, appID, roleID, func(result *clie2e.Result) bool { + return gjson.Get(result.Stdout, "data.role.name").String() == roleName && + gjson.Get(result.Stdout, "data.role.description").String() == createdDescription + }) + assert.Equal(t, roleID, gjson.Get(createdReadback.Stdout, "data.role.role_id").String(), "stdout:\n%s", createdReadback.Stdout) + + updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+role-update", "--app-id", appID, "--role-id", roleID, + "--description", updatedDescription, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + updateResult.AssertExitCode(t, 0) + updateResult.AssertStdoutStatus(t, true) + assert.Equal(t, roleID, gjson.Get(updateResult.Stdout, "data.role.role_id").String(), "stdout:\n%s", updateResult.Stdout) + readRoleUntil(t, ctx, appID, roleID, func(result *clie2e.Result) bool { + return gjson.Get(result.Stdout, "data.role.description").String() == updatedDescription + }) + + addResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+role-member-add", "--app-id", appID, "--role-id", roleID, member.flag, member.id}, + DefaultAs: "user", + }) + require.NoError(t, err) + addResult.AssertExitCode(t, 0) + addResult.AssertStdoutStatus(t, true) + + membersResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"apps", "+role-member-list", "--app-id", appID, "--role-id", roleID}, + DefaultAs: "user", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + return result == nil || result.ExitCode != 0 || !jsonStringArrayContains(result.Stdout, member.dataPath, member.id) + }, + }) + require.NoError(t, err) + membersResult.AssertExitCode(t, 0) + membersResult.AssertStdoutStatus(t, true) + assert.True(t, jsonStringArrayContains(membersResult.Stdout, member.dataPath, member.id), "stdout:\n%s", membersResult.Stdout) + + clearResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+role-member-remove", "--app-id", appID, "--role-id", roleID, "--all"}, + DefaultAs: "user", + Yes: true, + }) + require.NoError(t, err) + clearResult.AssertExitCode(t, 0) + clearResult.AssertStdoutStatus(t, true) + + clearedReadback, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"apps", "+role-member-list", "--app-id", appID, "--role-id", roleID}, + DefaultAs: "user", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + return result == nil || result.ExitCode != 0 || !allRoleMemberGroupsEmpty(result.Stdout) + }, + }) + require.NoError(t, err) + clearedReadback.AssertExitCode(t, 0) + clearedReadback.AssertStdoutStatus(t, true) + assert.True(t, allRoleMemberGroupsEmpty(clearedReadback.Stdout), "stdout:\n%s", clearedReadback.Stdout) + readRoleUntil(t, ctx, appID, roleID, func(result *clie2e.Result) bool { + return gjson.Get(result.Stdout, "data.role.role_id").String() == roleID + }) + + deleteResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+role-delete", "--app-id", appID, "--role-id", roleID}, + DefaultAs: "user", + Yes: true, + }) + require.NoError(t, err) + deleteResult.AssertExitCode(t, 0) + deleteResult.AssertStdoutStatus(t, true) + assert.Equal(t, roleID, gjson.Get(deleteResult.Stdout, "data.role_id").String(), "stdout:\n%s", deleteResult.Stdout) + assert.True(t, gjson.Get(deleteResult.Stdout, "data.deleted").Bool(), "stdout:\n%s", deleteResult.Stdout) + + absentResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"apps", "+role-list", "--app-id", appID, "--name", roleName}, + DefaultAs: "user", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + return result == nil || result.ExitCode != 0 || roleListContainsID(result.Stdout, roleID) + }, + }) + require.NoError(t, err) + absentResult.AssertExitCode(t, 0) + absentResult.AssertStdoutStatus(t, true) + require.False(t, roleListContainsID(absentResult.Stdout, roleID), "stdout:\n%s", absentResult.Stdout) + roleMayExist = false +} + +func TestAppsRoleMatchListLiveWorkflow(t *testing.T) { + requireLiveRoleFixture(t) + if os.Getenv("LARK_CLI_E2E_APPS_ROLE_MATCH_READY") != "1" { + t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ROLE_MATCH_READY=1 when backend user_role_list is ready for live role-match-list proof") + } + + appID := os.Getenv("LARK_CLI_E2E_APPS_ROLE_APP_ID") + userID := requireLiveRoleUserFixture(t) + roleID := liveAppsRoleFixtureID() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + baselineResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"apps", "+role-member-list", "--app-id", appID, "--role-id", roleID, "--member-type", "user"}, + DefaultAs: "user", + }, clie2e.RetryOptions{}) + require.NoError(t, err) + baselineResult.AssertExitCode(t, 0) + baselineResult.AssertStdoutStatus(t, true) + if jsonStringArrayContains(baselineResult.Stdout, "data.users", userID) { + t.Skipf("FIXTURE: user %s already belongs to role %s; refusing to mutate pre-existing state", userID, roleID) + } + needsMemberCleanup := false + t.Cleanup(func() { + if !needsMemberCleanup { + return + } + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + removeResult, removeErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"apps", "+role-member-remove", "--app-id", appID, "--role-id", roleID, "--users", userID}, + DefaultAs: "user", + Yes: true, + }) + clie2e.ReportCleanupFailure(t, "remove added apps role user "+userID, removeResult, removeErr) + }) + + // Arm cleanup before the write so a transport failure after a committed + // request cannot leak the user into the shared fixture role. + needsMemberCleanup = true + addResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+role-member-add", "--app-id", appID, "--role-id", roleID, "--users", userID}, + DefaultAs: "user", + }) + require.NoError(t, err) + addResult.AssertExitCode(t, 0) + addResult.AssertStdoutStatus(t, true) + + matchResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"apps", "+role-match-list", "--app-id", appID, "--user-id", userID}, + DefaultAs: "user", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + if result == nil || result.ExitCode != 0 { + return true + } + return !gjson.Get(result.Stdout, `data.roles.#(role_id=="`+roleID+`")`).Exists() + }, + }) + require.NoError(t, err) + matchResult.AssertExitCode(t, 0) + matchResult.AssertStdoutStatus(t, true) + assert.True(t, gjson.Get(matchResult.Stdout, `data.roles.#(role_id=="`+roleID+`")`).Exists(), "stdout:\n%s", matchResult.Stdout) +} + +func jsonStringArrayContains(raw, path, want string) bool { + for _, item := range gjson.Get(raw, path).Array() { + if item.String() == want { + return true + } + } + return false +} + +func roleListContainsID(raw, roleID string) bool { + for _, item := range gjson.Get(raw, "data.items").Array() { + if item.Get("role_id").String() == roleID { + return true + } + } + return false +} + +func allRoleMemberGroupsEmpty(raw string) bool { + for _, path := range []string{"data.users", "data.departments", "data.chats"} { + group := gjson.Get(raw, path) + if !group.Exists() || !group.IsArray() || len(group.Array()) != 0 { + return false + } + } + return true +} + +func readRoleUntil(t *testing.T, ctx context.Context, appID, roleID string, ready func(*clie2e.Result) bool) *clie2e.Result { + t.Helper() + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{"apps", "+role-get", "--app-id", appID, "--role-id", roleID}, + DefaultAs: "user", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + return result == nil || result.ExitCode != 0 || !ready(result) + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + return result +} + +func setAppsRoleDryRunEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "apps_role_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "apps_role_dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +} + +func validationEnvelope(result *clie2e.Result) string { + if result.Stdout != "" { + return result.Stdout + } + return result.Stderr +} + +func requireLiveRoleFixture(t *testing.T) { + t.Helper() + if os.Getenv("LARKSUITE_CLI_CONFIG_DIR") == "" { + t.Skip("FIXTURE: Set LARKSUITE_CLI_CONFIG_DIR to an isolated test config such as $HOME/.lark-cli-test; this live workflow must not use the default human profile") + } + if os.Getenv("LARK_CLI_E2E_APPS_ROLE_APP_ID") == "" { + t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ROLE_APP_ID to a dedicated test app where the test user can create/update/delete roles") + } + if liveAppsRoleFixtureID() == "" { + t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ROLE_ID to the dedicated test role") + } +} + +func requireLiveRoleLifecycleFixture(t *testing.T) { + t.Helper() + if os.Getenv("LARKSUITE_CLI_CONFIG_DIR") == "" { + t.Skip("FIXTURE: Set LARKSUITE_CLI_CONFIG_DIR to an isolated test config; lifecycle E2E must not use the default human profile") + } + if strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_APP_ID")) == "" { + t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ROLE_APP_ID to a dedicated test app where the test user can create/update/delete roles") + } + if strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_CHAT_OPEN_ID")) == "" && + strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_USER_OPEN_ID")) == "" { + t.Skip("FIXTURE: Set a chat or user open ID for the transient role lifecycle member step") + } +} + +func liveAppsRoleFixtureID() string { + return strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_ID")) +} + +type liveRoleMemberFixture struct { + flag string + id string + memberType string + dataPath string +} + +func requireLiveRoleMemberFixture(t *testing.T) liveRoleMemberFixture { + t.Helper() + if chatID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_CHAT_OPEN_ID")); chatID != "" { + return liveRoleMemberFixture{ + flag: "--chats", + id: chatID, + memberType: "chat", + dataPath: "data.chats", + } + } + if userID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_USER_OPEN_ID")); userID != "" { + return liveRoleMemberFixture{ + flag: "--users", + id: userID, + memberType: "user", + dataPath: "data.users", + } + } + t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ROLE_CHAT_OPEN_ID for chat-member live E2E, or LARK_CLI_E2E_APPS_ROLE_USER_OPEN_ID for user-member fallback") + return liveRoleMemberFixture{} +} + +func requireLiveRoleUserFixture(t *testing.T) string { + t.Helper() + if userID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_USER_OPEN_ID")); userID != "" { + return userID + } + t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ROLE_USER_OPEN_ID for live +role-match-list proof") + return "" +} diff --git a/tests/cli_e2e/apps/coverage.md b/tests/cli_e2e/apps/coverage.md index 4b4e708a14..42cd45bbc4 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: 9 leaf commands (all user-visible shortcuts) -- Command coverage: 100% (9/9) -- API dry-run coverage: 100% (7/7 API-backed commands) +- Denominator: 18 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts) +- Selected command coverage: 100% (18/18) +- API dry-run coverage: 100% (16/16 API-backed commands) - Local E2E coverage: 100% (2/2 local-only commands) -- Live coverage: 0% +- Live coverage: tracked role workflows are intentionally fixture-gated and skipped by default CI. When run manually with dedicated fixtures, a transient-role lifecycle covers create/get/update, member add/list/`--all` clear, role-presence readback, delete, and target-ID absence readback; shared-fixture workflows separately cover explicit member removal and `+role-match-list`. ## 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. @@ -17,8 +17,13 @@ - `TestAppsGitCredentialInitDryRun`: URL shape for issuing an app Git PAT; no body; `app_id` query metadata included. - `TestAppsGitCredentialListLocalE2E`: local-only command scans every app storage directory and reports repository URL and status without exposing PAT or expiry details. - `TestAppsGitCredentialRemoveLocalE2E`: local cleanup command removes app-scoped metadata under an isolated config dir. +- `TestAppsRoleManagementDryRun_RequestShapes`: role CRUD/member/match request shapes for all 9 role shortcuts. Request/response fields follow the API contract: `name`, `role_id`, `users`, `departments`, `chats`, `target_user_id`, and `roles`. +- `TestAppsRoleManagementValidation`: deterministic typed validation for invalid role ID, page bounds/token, missing update fields, missing member input, `--all` conflicts, and missing `--user-id`. +- `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. -Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Revisit when the server exposes `DELETE /apps/{appId}`. +Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Selected role read/member/match live flows intentionally remain fixture-gated and skipped by default because they mutate app role members. ## Command Table @@ -33,3 +38,12 @@ Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpo | ✓ | apps +git-credential-init | shortcut | apps_git_credential_dryrun_test.go::TestAppsGitCredentialInitDryRun | `--app-id`; dry-run `GET /open-apis/spark/v1/apps/{app_id}/git_info` | live blocked: issues short-lived repository PAT | | ✓ | apps +git-credential-list | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialListLocalE2E | no `--app-id`; scans all local app storage directories and reports `app_id`, repository URL, and status without PAT or expiry | local E2E only: no dry-run API because command is local read only | | ✓ | apps +git-credential-remove | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialRemoveLocalE2E | `--app-id`; deletes local metadata, keychain PAT, and Git config | local E2E only: no dry-run API because command is local cleanup only | +| ✓ | apps +role-list | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes | `--app-id`; `--name` -> `name`; `--page-size` -> `limit`; `--page-token` -> `offset` | live depends on a role-capable app fixture | +| ✓ | apps +role-get | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes | `GET /roles/:role_id`; no body/query | live covered by fixture-gated role workflow | +| ✓ | apps +role-create | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleLifecycleLiveWorkflow | `POST /roles`; required `name`; optional `role_id` | transient live role is independently read back | +| ✓ | apps +role-update | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleLifecycleLiveWorkflow | `PATCH /roles/:role_id`; only changed fields | transient live role update is independently read back | +| ✓ | apps +role-delete | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleLifecycleLiveWorkflow | `DELETE /roles/:role_id`; high-risk confirmation | live flow verifies the target `role_id` is absent after deletion | +| ✓ | apps +role-member-list | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes | `GET /member_list`; optional `member_type=user/department/chat`; no pagination | live covered by fixture-gated role workflow for a provided chat member when available, otherwise a user member | +| ✓ | 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 |