Compare commits

...
5 changed files with 1198 additions and 53 deletions
+11 -1
View File
@@ -6,6 +6,17 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Fixed
- **Fail-closed devapp list pagination** (#917) — the paginated `dws devapp`
list shortcuts (`+list`, `+permission-list`, `+event-list`, and
`+version-list`) now preserve `hasMore` and `nextCursor` with each single-page
result, forward opaque cursors byte-for-byte (including surrounding or
whitespace-only values), normalize deployed providers' terminal missing,
null, empty, or last-observed cursor to `nextCursor: ""`, and reject malformed
or ambiguous pagination pages and unprojectable app items instead of emitting
terminal-looking partial results.
## [1.0.58-beta.2] - 2026-08-10
### Added
@@ -32,7 +43,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
## [1.0.58-beta.1] - 2026-08-07
### Added
+233
View File
@@ -0,0 +1,233 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"context"
"encoding/json"
"io"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func executeDevAppListRoot(t *testing.T, args ...string) (string, string, error) {
return executeDevAppRoot(t, "+list", args...)
}
func executeDevAppRoot(t *testing.T, command string, args ...string) (string, string, error) {
t.Helper()
helpers.InitDepsForTest(t, helpers.GetCaller())
root := NewRootCommand()
var stdout bytes.Buffer
var stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
root.SetArgs(append([]string{"devapp", command}, args...))
err := root.Execute()
return stdout.String(), stderr.String(), err
}
func TestCrossPlatformCoverageDevAppPaginatedShortcutsMock(t *testing.T) {
for _, test := range []struct {
name string
command string
collection string
args []string
}{
{name: "apps", command: "+list", collection: "apps"},
{name: "permissions", command: "+permission-list", collection: "permissions", args: []string{"--unified-app-id", "X"}},
{name: "events", command: "+event-list", collection: "events", args: []string{"--unified-app-id", "X"}},
{name: "versions", command: "+version-list", collection: "versions", args: []string{"--unified-app-id", "X"}},
} {
t.Run(test.name, func(t *testing.T) {
args := append(append([]string(nil), test.args...), "--mock", "--format", "json")
stdout, stderr, err := executeDevAppRoot(t, test.command, args...)
if err != nil {
t.Fatalf("mock %s error = %v, stderr=%q", test.command, err, stderr)
}
if stderr != "" {
t.Fatalf("mock %s stderr = %q", test.command, stderr)
}
var payload map[string]any
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
t.Fatalf("decode mock %s output: %v, output=%q", test.command, err, stdout)
}
items, ok := payload[test.collection].([]any)
if len(payload) != 4 || !ok || len(items) != 0 || payload["count"] != float64(0) ||
payload["hasMore"] != false || payload["nextCursor"] != "" {
t.Fatalf("mock %s payload = %#v", test.command, payload)
}
})
}
}
func TestCrossPlatformCoverageDevAppListMockPaginationFormats(t *testing.T) {
for _, format := range []string{"json", "raw", "ndjson", "pretty"} {
t.Run(format, func(t *testing.T) {
if format == "pretty" {
t.Setenv("NO_COLOR", "1")
}
stdout, stderr, err := executeDevAppListRoot(t, "--mock", "--format", format)
if err != nil {
t.Fatalf("mock list error = %v, stderr=%q", err, stderr)
}
if stderr != "" {
t.Fatalf("mock list stderr = %q", stderr)
}
if format == "pretty" {
values := map[string]string{}
lines := strings.Split(strings.TrimSuffix(stdout, "\n"), "\n")
if len(lines) != 4 {
t.Fatalf("pretty mock lines = %d, want four: %q", len(lines), stdout)
}
for _, line := range lines {
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
t.Fatalf("pretty mock line is not key/value: %q", line)
}
values[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
if len(values) != 4 || values["apps"] != "[]" || values["count"] != "0" ||
values["hasMore"] != "false" || values["nextCursor"] != "" {
t.Fatalf("pretty mock values = %#v, output=%q", values, stdout)
}
return
}
if format == "ndjson" && len(strings.Split(strings.TrimSuffix(stdout, "\n"), "\n")) != 1 {
t.Fatalf("ndjson mock output expanded envelope: %q", stdout)
}
var payload map[string]any
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
t.Fatalf("decode mock output: %v, output=%q", err, stdout)
}
apps, ok := payload["apps"].([]any)
if len(payload) != 4 || !ok || len(apps) != 0 || payload["count"] != float64(0) ||
payload["hasMore"] != false || payload["nextCursor"] != "" {
t.Fatalf("mock pagination payload = %#v", payload)
}
})
}
}
type devAppListFixtureRunner struct {
response string
}
func (r devAppListFixtureRunner) Run(
_ context.Context,
invocation executor.Invocation,
) (executor.Result, error) {
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"content": []any{map[string]any{"type": "text", "text": r.response}},
},
}, nil
}
func TestCrossPlatformCoverageDevAppListInvalidContractJSONError(t *testing.T) {
helpers.InitDepsForTest(t, helpers.GetCaller())
testseam.Swap(t, &rootNewCommandRunnerWithFlags, func(*GlobalFlags) executor.Runner {
return devAppListFixtureRunner{response: `{
"apps":[{"unifiedAppId":"would-be-partial"},"payload-do-not-leak"],
"hasMore":false,
"nextCursor":""
}`}
})
root := NewRootCommand()
var stdout bytes.Buffer
var cobraStderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&cobraStderr)
root.SetArgs([]string{"devapp", "+list", "--cursor", "cursor-do-not-leak", "--format", "json"})
err := root.Execute()
if err == nil {
t.Fatal("invalid pagination contract unexpectedly succeeded")
}
if stdout.Len() != 0 || cobraStderr.Len() != 0 {
t.Fatalf("invalid contract leaked command output: stdout=%q stderr=%q", stdout.String(), cobraStderr.String())
}
if code := apperrors.ExitCode(err); code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
var machineStderr bytes.Buffer
if printErr := printExecutionError(root, &stdout, &machineStderr, err); printErr != nil {
t.Fatalf("print JSON error: %v", printErr)
}
if stdout.Len() != 0 {
t.Fatalf("JSON error wrote stdout: %q", stdout.String())
}
decoder := json.NewDecoder(strings.NewReader(machineStderr.String()))
var payload map[string]any
if err := decoder.Decode(&payload); err != nil {
t.Fatalf("decode stderr JSON: %v, stderr=%q", err, machineStderr.String())
}
if err := decoder.Decode(&map[string]any{}); err != io.EOF {
t.Fatalf("stderr contained more than one JSON value: %v, stderr=%q", err, machineStderr.String())
}
errorPayload, ok := payload["error"].(map[string]any)
message, messageOK := errorPayload["message"].(string)
if len(payload) != 1 || !ok || len(errorPayload) != 4 ||
errorPayload["category"] != "api" || errorPayload["code"] != float64(1) ||
errorPayload["reason"] != "devapp_pagination_contract_invalid" ||
!messageOK || strings.TrimSpace(message) == "" {
t.Fatalf("JSON error contract = %#v", payload)
}
if strings.Contains(machineStderr.String(), "cursor-do-not-leak") ||
strings.Contains(machineStderr.String(), "payload-do-not-leak") ||
strings.Contains(machineStderr.String(), "would-be-partial") {
t.Fatalf("JSON error leaked request or response data: %q", machineStderr.String())
}
}
func TestCrossPlatformCoverageDevAppListMockIsolation(t *testing.T) {
helpers.InitDepsForTest(t, helpers.GetCaller())
runner := newCommandRunnerWithFlags(&GlobalFlags{Mock: true})
for _, test := range []struct {
name string
product string
tool string
}{
{name: "other devapp tool", product: "devapp", tool: "get_dev_app"},
{name: "other product", product: "contact", tool: "list_contacts"},
} {
t.Run(test.name, func(t *testing.T) {
invocation := executor.NewHelperInvocation(
"mock.isolation", test.product, test.tool, map[string]any{},
)
result, err := runner.Run(context.Background(), invocation)
if err != nil {
t.Fatalf("mock invocation error = %v", err)
}
content, ok := result.Response["content"].(map[string]any)
if !ok {
t.Fatalf("mock content = %#v", result.Response["content"])
}
mockResult, ok := content["result"].([]any)
if !ok || len(mockResult) != 0 {
t.Fatalf("non-target mock result changed = %#v", content["result"])
}
})
}
}
+17 -1
View File
@@ -589,13 +589,29 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
// Mock mode: return predefined mock response without network call.
if r.globalFlags != nil && r.globalFlags.Mock {
invocation.Implemented = true
result := any([]any{})
if invocation.CanonicalProduct == "devapp" {
collectionKey := map[string]string{
"list_dev_app": "apps",
"list_dev_app_permissions": "items",
"list_dev_app_events": "events",
"list_dev_app_versions": "items",
}[invocation.Tool]
if collectionKey != "" {
result = map[string]any{
collectionKey: []any{},
"hasMore": false,
"nextCursor": "",
}
}
}
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"endpoint": transport.RedactURL(endpoint),
"content": map[string]any{
"success": true,
"result": []any{},
"result": result,
"_mock": true,
"_tool": invocation.Tool,
},
+272 -51
View File
@@ -30,17 +30,20 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const productDevApp = "devapp"
// applyCursor forwards --cursor/--page-size into params, matching the helper's
// pass-through pagination (pageSize defaults to 20, cursor omitted on page 1).
func applyCursor(rt *shortcut.RuntimeContext, params map[string]any) {
// applyCursor forwards --cursor/--page-size into params and returns the opaque
// request cursor for response-contract validation. pageSize defaults to 20 and
// cursor is omitted on page 1.
func applyCursor(rt *shortcut.RuntimeContext, params map[string]any) string {
requestCursor := listAppRequestCursor(rt)
if rt.Changed("cursor") {
if cur := rt.Str("cursor"); cur != "" {
params["cursor"] = cur
if requestCursor != "" {
params["cursor"] = requestCursor
}
}
size := rt.Int("page-size")
@@ -48,6 +51,15 @@ func applyCursor(rt *shortcut.RuntimeContext, params map[string]any) {
size = 20
}
params["pageSize"] = size
return requestCursor
}
func listAppRequestCursor(rt *shortcut.RuntimeContext) string {
if rt == nil || rt.Command() == nil {
return ""
}
value, _ := rt.Command().Flags().GetString("cursor")
return value
}
var cursorFlags = []shortcut.Flag{
@@ -105,7 +117,7 @@ var ListApp = shortcut.Shortcut{
}, cursorFlags...),
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
applyCursor(rt, params)
requestCursor := applyCursor(rt, params)
if rt.Changed("name") {
params["name"] = rt.Str("name")
}
@@ -137,23 +149,229 @@ var ListApp = shortcut.Shortcut{
if err != nil {
return err
}
apps := listAppProject(data)
return rt.Output(map[string]any{"count": len(apps), "apps": apps})
page, err := listAppParsePage(data, requestCursor)
if err != nil {
return err
}
apps, err := listAppProject(page.apps)
if err != nil {
return err
}
return rt.Output(map[string]any{
"count": len(apps),
"apps": apps,
"hasMore": page.hasMore,
"nextCursor": page.nextCursor,
})
},
}
var listAppCollectionKeys = []string{
"list", "items", "apps", "appList", "permissions", "events", "versions", "result", "data",
}
type listAppPage struct {
apps []any
hasMore bool
nextCursor string
}
type listAppPageCandidate struct {
envelope map[string]any
apps []any
}
// listAppParsePage accepts one page at the response top level or under exactly
// one existing one-level list envelope. The app list and both pagination facts
// must come from that same map; otherwise callers cannot safely distinguish a
// terminal page from a truncated or conflicting response.
func listAppParsePage(data map[string]any, requestCursor string) (listAppPage, error) {
return devAppListParsePage(data, requestCursor, "list_dev_app")
}
func devAppListParsePage(data map[string]any, requestCursor, tool string) (listAppPage, error) {
candidates, invalid := listAppPageCandidates(data)
if invalid || len(candidates) != 1 {
return listAppPage{}, devAppPaginationContractError(tool)
}
candidate := candidates[0]
hasMoreValue, ok := candidate.envelope["hasMore"]
if !ok {
return listAppPage{}, devAppPaginationContractError(tool)
}
hasMore, ok := hasMoreValue.(bool)
if !ok {
return listAppPage{}, devAppPaginationContractError(tool)
}
nextCursorValue, ok := candidate.envelope["nextCursor"]
if hasMore {
if !ok {
return listAppPage{}, devAppPaginationContractError(tool)
}
nextCursor, ok := nextCursorValue.(string)
if !ok {
return listAppPage{}, devAppPaginationContractError(tool)
}
if nextCursor == "" || nextCursor == requestCursor {
return listAppPage{}, devAppPaginationContractError(tool)
}
return listAppPage{
apps: candidate.apps,
hasMore: true,
nextCursor: nextCursor,
}, nil
}
// Deployed devapp providers use hasMore as the terminal authority and may
// omit nextCursor or return null, an empty string, or the last observed
// cursor on a terminal page. Normalize all provider-compatible forms to an
// empty public cursor.
if ok && nextCursorValue != nil {
if _, ok := nextCursorValue.(string); !ok {
return listAppPage{}, devAppPaginationContractError(tool)
}
}
return listAppPage{
apps: candidate.apps,
hasMore: false,
nextCursor: "",
}, nil
}
// listAppPageCandidates enumerates only the response map and supported maps
// directly beneath it. Candidate collection keys that have a wrong type,
// multiple list-bearing maps, multiple lists in one map, pagination metadata
// without its sibling list, or a second nested envelope all fail closed.
func listAppPageCandidates(data map[string]any) ([]listAppPageCandidate, bool) {
if data == nil {
return nil, true
}
type envelope struct {
value map[string]any
depth int
}
envelopes := []envelope{{value: data}}
for key, value := range data {
inner, ok := value.(map[string]any)
if ok && listAppIsCollectionKey(key) {
envelopes = append(envelopes, envelope{value: inner, depth: 1})
}
}
candidates := make([]listAppPageCandidate, 0, 1)
for _, current := range envelopes {
for key, value := range current.value {
if listAppIsCollectionKey(key) {
continue
}
inner, ok := value.(map[string]any)
if ok && listAppHasPageEvidence(inner) {
return nil, true
}
}
lists := make([][]any, 0, 1)
invalidCollection := false
for _, key := range listAppCollectionKeys {
value, exists := current.value[key]
if !exists {
continue
}
switch typed := value.(type) {
case []any:
lists = append(lists, typed)
case map[string]any:
if current.depth > 0 {
invalidCollection = true
}
default:
invalidCollection = true
}
}
_, hasHasMore := current.value["hasMore"]
_, hasNextCursor := current.value["nextCursor"]
hasPageEvidence := len(lists) > 0 || hasHasMore || hasNextCursor || invalidCollection
if !hasPageEvidence {
continue
}
if invalidCollection || len(lists) != 1 {
return nil, true
}
candidates = append(candidates, listAppPageCandidate{
envelope: current.value,
apps: lists[0],
})
}
return candidates, false
}
func listAppIsCollectionKey(candidate string) bool {
for _, key := range listAppCollectionKeys {
if candidate == key {
return true
}
}
return false
}
func listAppHasPageEvidence(envelope map[string]any) bool {
if _, ok := envelope["hasMore"]; ok {
return true
}
if _, ok := envelope["nextCursor"]; ok {
return true
}
for _, key := range listAppCollectionKeys {
value, ok := envelope[key]
if !ok {
continue
}
switch typed := value.(type) {
case []any:
return true
case map[string]any:
if key == "apps" || key == "appList" || listAppHasPageEvidence(typed) {
return true
}
default:
if key == "apps" || key == "appList" {
return true
}
}
}
return false
}
// DEC-DWS-917-R1B treats an unprojectable app item as a page-integrity failure:
// silently dropping it could turn a real app into a false terminal "not found".
// Keep the issue's frozen page-contract reason for both envelope and item failures.
func listAppPaginationContractError() error {
return devAppPaginationContractError("list_dev_app")
}
func devAppPaginationContractError(tool string) error {
return apperrors.NewAPI(
tool+" returned an invalid pagination contract",
apperrors.WithReason("devapp_pagination_contract_invalid"),
)
}
// listAppProject reshapes list_dev_app into a clean app list
// ({unifiedAppId, name, appKey, agentId, status, gmtModified}) — output-projection
// clean output projection. The list container and per-item field names are probed
// defensively across candidate keys, so an unknown/empty shape yields an empty
// list rather than a crash or fabricated data.
func listAppProject(data map[string]any) []map[string]any {
raw := listAppFindList(data)
// clean output projection. The page container has already passed the strict
// same-envelope pagination contract; per-item field aliases remain compatible.
func listAppProject(raw []any) ([]map[string]any, error) {
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
return nil, listAppPaginationContractError()
}
row := map[string]any{}
if v, ok := listAppFirst(m, "unifiedAppId", "unified_app_id"); ok {
@@ -174,36 +392,12 @@ func listAppProject(data map[string]any) []map[string]any {
if v, ok := listAppFirst(m, "gmtModified", "gmt_modified", "modifyTime", "modified_time"); ok {
row["gmtModified"] = v
}
if len(row) > 0 {
out = append(out, row)
if len(row) == 0 {
return nil, listAppPaginationContractError()
}
out = append(out, row)
}
return out
}
// listAppFindList locates the app list payload, tolerating a bare top-level
// array or nesting one level under a common envelope key.
func listAppFindList(data map[string]any) []any {
if data == nil {
return []any{}
}
for _, k := range []string{"list", "items", "apps", "appList", "result", "data"} {
v, ok := data[k]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "apps", "appList", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
return out, nil
}
// listAppFirst returns the first present candidate key's value.
@@ -665,13 +859,22 @@ var PermissionList = shortcut.Shortcut{
if rt.Changed("api-status") {
params["apiStatus"] = rt.Str("api-status")
}
applyCursor(rt, params)
requestCursor := applyCursor(rt, params)
data, err := rt.CallMCPData(productDevApp, "list_dev_app_permissions", params)
if err != nil {
return err
}
permissions := permissionListProject(data)
return rt.Output(map[string]any{"count": len(permissions), "permissions": permissions})
page, err := devAppListParsePage(data, requestCursor, "list_dev_app_permissions")
if err != nil {
return err
}
permissions := permissionListProject(map[string]any{"items": page.apps})
return rt.Output(map[string]any{
"count": len(permissions),
"permissions": permissions,
"hasMore": page.hasMore,
"nextCursor": page.nextCursor,
})
},
}
@@ -1143,13 +1346,22 @@ var EventList = shortcut.Shortcut{
if rt.Changed("keyword") {
params["keyword"] = rt.Str("keyword")
}
applyCursor(rt, params)
requestCursor := applyCursor(rt, params)
data, err := rt.CallMCPData(productDevApp, "list_dev_app_events", params)
if err != nil {
return err
}
events := eventListProject(data)
return rt.Output(map[string]any{"count": len(events), "events": events})
page, err := devAppListParsePage(data, requestCursor, "list_dev_app_events")
if err != nil {
return err
}
events := eventListProject(map[string]any{"events": page.apps})
return rt.Output(map[string]any{
"count": len(events),
"events": events,
"hasMore": page.hasMore,
"nextCursor": page.nextCursor,
})
},
}
@@ -1330,13 +1542,22 @@ var VersionList = shortcut.Shortcut{
}, cursorFlags...),
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")}
applyCursor(rt, params)
requestCursor := applyCursor(rt, params)
data, err := rt.CallMCPData(productDevApp, "list_dev_app_versions", params)
if err != nil {
return err
}
versions := versionListProject(data)
return rt.Output(map[string]any{"count": len(versions), "versions": versions})
page, err := devAppListParsePage(data, requestCursor, "list_dev_app_versions")
if err != nil {
return err
}
versions := versionListProject(map[string]any{"items": page.apps})
return rt.Output(map[string]any{
"count": len(versions),
"versions": versions,
"hasMore": page.hasMore,
"nextCursor": page.nextCursor,
})
},
}
@@ -0,0 +1,665 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package devapp
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type listPaginationCall struct {
product string
tool string
params map[string]any
}
type listPaginationCaller struct {
response string
calls []listPaginationCall
}
func (c *listPaginationCaller) CallTool(
_ context.Context,
product string,
tool string,
params map[string]any,
) (*edition.ToolResult, error) {
cloned := make(map[string]any, len(params))
for key, value := range params {
cloned[key] = value
}
c.calls = append(c.calls, listPaginationCall{product: product, tool: tool, params: cloned})
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.response}}}, nil
}
func (*listPaginationCaller) Format() string { return "json" }
func (*listPaginationCaller) DryRun() bool { return false }
func (*listPaginationCaller) Fields() string { return "" }
func (*listPaginationCaller) JQ() string { return "" }
func executeListPagination(
t *testing.T,
response string,
args ...string,
) (string, error, *listPaginationCaller) {
t.Helper()
caller := &listPaginationCaller{response: response}
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().StringP("format", "f", "json", "")
root.PersistentFlags().String("fields", "", "")
root.PersistentFlags().String("jq", "", "")
service := &cobra.Command{Use: "devapp"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(ListApp)))
root.AddCommand(service)
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
root.SetArgs(append([]string{"devapp", "+list"}, args...))
err := root.Execute()
return output.String(), err, caller
}
func executeDevAppListShortcut(
t *testing.T,
command string,
definition shortcut.Shortcut,
response string,
args ...string,
) (string, error, *listPaginationCaller) {
t.Helper()
caller := &listPaginationCaller{response: response}
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().StringP("format", "f", "json", "")
root.PersistentFlags().String("fields", "", "")
root.PersistentFlags().String("jq", "", "")
service := &cobra.Command{Use: "devapp"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(definition)))
root.AddCommand(service)
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
root.SetArgs(append([]string{"devapp", command}, args...))
err := root.Execute()
return output.String(), err, caller
}
func decodeListPaginationEnvelope(t *testing.T, output string) map[string]any {
t.Helper()
var payload map[string]any
if err := json.Unmarshal([]byte(output), &payload); err != nil {
t.Fatalf("decode output: %v\noutput=%q", err, output)
}
return payload
}
func decodePrettyListPagination(t *testing.T, output string) map[string]string {
t.Helper()
values := map[string]string{}
lines := strings.Split(strings.TrimSuffix(output, "\n"), "\n")
if len(lines) != 4 {
t.Fatalf("pretty output lines = %d, want four fields: %q", len(lines), output)
}
for _, line := range lines {
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
t.Fatalf("pretty output line is not key/value: %q", line)
}
values[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
return values
}
func TestCrossPlatformCoverageListPaginationNilRuntimeCursor(t *testing.T) {
if got := listAppRequestCursor(nil); got != "" {
t.Fatalf("nil runtime cursor = %q, want empty", got)
}
}
func TestCrossPlatformCoverageAllDevAppPaginatedListsPreservePagination(t *testing.T) {
const requestCursor = "opaque-current"
const nextCursor = "opaque-next"
tests := []struct {
name string
command string
definition shortcut.Shortcut
tool string
listKey string
response string
args []string
}{
{
name: "permissions",
command: "+permission-list",
definition: PermissionList,
tool: "list_dev_app_permissions",
listKey: "permissions",
response: `{"result":{"items":[{"scopeValue":"Contact.User.Read"}],"hasMore":true,"nextCursor":"opaque-next"}}`,
args: []string{"--unified-app-id", "app-1"},
},
{
name: "events",
command: "+event-list",
definition: EventList,
tool: "list_dev_app_events",
listKey: "events",
response: `{"result":{"events":[{"eventCode":"chat_update"}],"hasMore":true,"nextCursor":"opaque-next"}}`,
args: []string{"--unified-app-id", "app-1"},
},
{
name: "versions",
command: "+version-list",
definition: VersionList,
tool: "list_dev_app_versions",
listKey: "versions",
response: `{"result":{"items":[{"versionId":"version-1"}],"hasMore":true,"nextCursor":"opaque-next"}}`,
args: []string{"--unified-app-id", "app-1"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
args := append([]string{}, test.args...)
args = append(args, "--cursor", requestCursor, "--page-size", "1", "--format", "json")
output, err, caller := executeDevAppListShortcut(
t, test.command, test.definition, test.response, args...,
)
if err != nil {
t.Fatalf("execute %s: %v", test.command, err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.product != productDevApp || call.tool != test.tool ||
call.params["cursor"] != requestCursor || call.params["pageSize"] != 1 {
t.Fatalf("call = %#v", call)
}
payload := decodeListPaginationEnvelope(t, output)
items, ok := payload[test.listKey].([]any)
if len(payload) != 4 || !ok || len(items) != 1 || payload["count"] != float64(1) ||
payload["hasMore"] != true || payload["nextCursor"] != nextCursor {
t.Fatalf("pagination payload = %#v", payload)
}
})
}
}
func TestCrossPlatformCoverageListPaginationTerminalFormats(t *testing.T) {
response := `{
"apps":[{
"unified_app_id":"app-1",
"appName":"Alpha",
"clientId":"client-1",
"agent_id":42,
"appStatus":"ENABLED",
"modifyTime":"2026-08-09T00:00:00Z",
"ignored":"drop-me"
}],
"hasMore":false,
"nextCursor":""
}`
for _, format := range []string{"json", "raw", "ndjson", "pretty"} {
t.Run(format, func(t *testing.T) {
if format == "pretty" {
t.Setenv("NO_COLOR", "1")
}
output, err, caller := executeListPagination(t, response, "--format", format)
if err != nil {
t.Fatalf("execute terminal page: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one page", caller.calls)
}
if format == "pretty" {
values := decodePrettyListPagination(t, output)
if len(values) != 4 || values["count"] != "1" || values["hasMore"] != "false" ||
values["nextCursor"] != "" || !strings.Contains(values["apps"], `"name":"Alpha"`) {
t.Fatalf("pretty pagination values = %#v, output=%q", values, output)
}
return
}
if format == "ndjson" && len(strings.Split(strings.TrimSuffix(output, "\n"), "\n")) != 1 {
t.Fatalf("ndjson expanded the page envelope: %q", output)
}
payload := decodeListPaginationEnvelope(t, output)
if len(payload) != 4 || payload["count"] != float64(1) || payload["hasMore"] != false || payload["nextCursor"] != "" {
t.Fatalf("terminal pagination payload = %#v", payload)
}
apps, ok := payload["apps"].([]any)
if !ok || len(apps) != 1 {
t.Fatalf("apps = %#v", payload["apps"])
}
app := apps[0].(map[string]any)
want := map[string]any{
"unifiedAppId": "app-1",
"name": "Alpha",
"appKey": "client-1",
"agentId": float64(42),
"status": "ENABLED",
"gmtModified": "2026-08-09T00:00:00Z",
}
if !reflect.DeepEqual(app, want) {
t.Fatalf("app projection = %#v, want %#v", app, want)
}
})
}
}
func TestCrossPlatformCoverageListPaginationValidItemProjectionIsOneToOne(t *testing.T) {
response := `{
"apps":[
{"app_name":"Single","unknown":"drop-me"},
{"unifiedAppId":"primary-id","unified_app_id":"secondary-id"},
{"agent_id":null}
],
"hasMore":false,
"nextCursor":""
}`
output, err, caller := executeListPagination(t, response, "--format", "json")
if err != nil {
t.Fatalf("valid item projection rejected: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
payload := decodeListPaginationEnvelope(t, output)
apps, ok := payload["apps"].([]any)
if len(payload) != 4 || !ok || len(apps) != 3 || payload["count"] != float64(3) {
t.Fatalf("one-to-one payload = %#v", payload)
}
want := []any{
map[string]any{"name": "Single"},
map[string]any{"unifiedAppId": "primary-id"},
map[string]any{"agentId": nil},
}
if !reflect.DeepEqual(apps, want) {
t.Fatalf("projection = %#v, want %#v", apps, want)
}
}
func TestCrossPlatformCoverageListPaginationSupportedOneLevelEnvelopes(t *testing.T) {
for _, outerKey := range listAppCollectionKeys {
for _, innerKey := range listAppCollectionKeys {
name := outerKey + "/" + innerKey
t.Run(name, func(t *testing.T) {
response, err := json.Marshal(map[string]any{
outerKey: map[string]any{
innerKey: []any{map[string]any{"unifiedAppId": "app-envelope"}},
"hasMore": false,
"nextCursor": "",
},
})
if err != nil {
t.Fatal(err)
}
output, executeErr, caller := executeListPagination(t, string(response), "--format", "json")
if executeErr != nil {
t.Fatalf("supported envelope rejected: %v, response=%s", executeErr, response)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
payload := decodeListPaginationEnvelope(t, output)
if len(payload) != 4 || payload["count"] != float64(1) ||
payload["hasMore"] != false || payload["nextCursor"] != "" {
t.Fatalf("envelope payload = %#v", payload)
}
})
}
}
}
func TestCrossPlatformCoverageListPaginationRejectsHiddenPageInSupportedEnvelopes(t *testing.T) {
for _, outerKey := range listAppCollectionKeys {
t.Run(outerKey, func(t *testing.T) {
response, err := json.Marshal(map[string]any{
outerKey: map[string]any{
"apps": []any{},
"hasMore": false,
"nextCursor": "",
"payload": map[string]any{
"apps": []any{},
"hasMore": false,
"nextCursor": "",
},
},
})
if err != nil {
t.Fatal(err)
}
output, executeErr, caller := executeListPagination(t, string(response), "--format", "json")
if executeErr == nil {
t.Fatalf("hidden page unexpectedly accepted: %s", response)
}
if output != "" || len(caller.calls) != 1 {
t.Fatalf("hidden page output=%q calls=%#v", output, caller.calls)
}
var typed *apperrors.Error
if !errors.As(executeErr, &typed) || typed.Reason != "devapp_pagination_contract_invalid" {
t.Fatalf("hidden page error = %T %#v", executeErr, executeErr)
}
})
}
}
func TestCrossPlatformCoverageListPaginationAllowsNonPageMetadataSiblings(t *testing.T) {
nonPageMetadata := map[string]any{
"data": "trace-data",
"result": "trace-result",
"list": "trace-list",
"items": "trace-items",
}
for _, test := range []struct {
name string
response map[string]any
}{
{
name: "top-level page",
response: map[string]any{
"apps": []any{},
"hasMore": false,
"nextCursor": "",
"metadata": nonPageMetadata,
},
},
{
name: "supported wrapper",
response: map[string]any{
"result": map[string]any{
"apps": []any{},
"hasMore": false,
"nextCursor": "",
"metadata": nonPageMetadata,
},
},
},
} {
t.Run(test.name, func(t *testing.T) {
response, err := json.Marshal(test.response)
if err != nil {
t.Fatal(err)
}
output, executeErr, caller := executeListPagination(t, string(response), "--format", "json")
if executeErr != nil {
t.Fatalf("non-page metadata rejected: %v, response=%s", executeErr, response)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
payload := decodeListPaginationEnvelope(t, output)
if len(payload) != 4 || payload["count"] != float64(0) ||
payload["hasMore"] != false || payload["nextCursor"] != "" {
t.Fatalf("non-page metadata payload = %#v", payload)
}
})
}
}
func TestCrossPlatformCoverageListPaginationOneLevelAndOpaqueCursor(t *testing.T) {
requestCursor := " opaque/request/+==:? "
nextCursor := "opaque/next/+==:?"
response := fmt.Sprintf(`{
"result":{
"items":[{"unifiedAppId":"app-2","name":"Beta"}],
"hasMore":true,
"nextCursor":%q
}
}`, nextCursor)
output, err, caller := executeListPagination(t, response,
"--cursor", requestCursor,
"--page-size", "7",
"--name", "Beta",
"--app-key", "client-2",
"--format", "json",
)
if err != nil {
t.Fatalf("execute non-terminal page: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one page without auto-pagination", caller.calls)
}
call := caller.calls[0]
if call.product != "devapp" || call.tool != "list_dev_app" {
t.Fatalf("call target = %s/%s", call.product, call.tool)
}
wantParams := map[string]any{
"cursor": requestCursor,
"pageSize": 7,
"name": "Beta",
"appKey": "client-2",
}
if !reflect.DeepEqual(call.params, wantParams) {
t.Fatalf("params = %#v, want %#v", call.params, wantParams)
}
payload := decodeListPaginationEnvelope(t, output)
if len(payload) != 4 || payload["hasMore"] != true || payload["nextCursor"] != nextCursor || payload["count"] != float64(1) {
t.Fatalf("non-terminal payload = %#v", payload)
}
}
func TestCrossPlatformCoverageListPaginationNormalizesDeployedTerminalEnvelope(t *testing.T) {
response := `{
"arguments":[],
"errorCode":null,
"errorMsg":null,
"result":{
"items":[{"unifiedAppId":"app-live","name":"Live App"}],
"hasMore":false,
"nextCursor":"4010069592"
},
"success":true
}`
output, err, caller := executeListPagination(t, response, "--format", "json")
if err != nil {
t.Fatalf("deployed terminal envelope rejected: %v", err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].params, map[string]any{"pageSize": 20}) {
t.Fatalf("calls = %#v", caller.calls)
}
payload := decodeListPaginationEnvelope(t, output)
apps, ok := payload["apps"].([]any)
if len(payload) != 4 || !ok || len(apps) != 1 || payload["count"] != float64(1) ||
payload["hasMore"] != false || payload["nextCursor"] != "" {
t.Fatalf("normalized deployed terminal payload = %#v", payload)
}
}
func TestCrossPlatformCoverageListPaginationValidEmptyPages(t *testing.T) {
for _, test := range []struct {
name string
response string
args []string
wantHasMore bool
wantNext string
wantRequest map[string]any
}{
{
name: "terminal",
response: `{"apps":[],"hasMore":false,"nextCursor":""}`,
wantHasMore: false,
wantNext: "",
wantRequest: map[string]any{"pageSize": 20},
},
{
name: "terminal omitted provider cursor",
response: `{"apps":[],"hasMore":false}`,
wantHasMore: false,
wantNext: "",
wantRequest: map[string]any{"pageSize": 20},
},
{
name: "terminal null provider cursor",
response: `{"apps":[],"hasMore":false,"nextCursor":null}`,
wantHasMore: false,
wantNext: "",
wantRequest: map[string]any{"pageSize": 20},
},
{
name: "terminal last provider cursor",
response: `{"apps":[],"hasMore":false,"nextCursor":"4010069592"}`,
wantHasMore: false,
wantNext: "",
wantRequest: map[string]any{"pageSize": 20},
},
{
name: "non-terminal",
response: `{"apps":[],"hasMore":true,"nextCursor":"after-empty"}`,
args: []string{"--cursor", "before-empty"},
wantHasMore: true,
wantNext: "after-empty",
wantRequest: map[string]any{"cursor": "before-empty", "pageSize": 20},
},
{
name: "explicit empty request cursor",
response: `{"apps":[],"hasMore":false,"nextCursor":""}`,
args: []string{"--cursor", ""},
wantHasMore: false,
wantNext: "",
wantRequest: map[string]any{"pageSize": 20},
},
} {
t.Run(test.name, func(t *testing.T) {
args := append(append([]string(nil), test.args...), "--format", "json")
output, err, caller := executeListPagination(t, test.response, args...)
if err != nil {
t.Fatalf("valid empty page rejected: %v", err)
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].params, test.wantRequest) {
t.Fatalf("calls = %#v, want params %#v", caller.calls, test.wantRequest)
}
payload := decodeListPaginationEnvelope(t, output)
apps, ok := payload["apps"].([]any)
if len(payload) != 4 || !ok || len(apps) != 0 || payload["count"] != float64(0) ||
payload["hasMore"] != test.wantHasMore || payload["nextCursor"] != test.wantNext {
t.Fatalf("empty page payload = %#v", payload)
}
})
}
}
func TestCrossPlatformCoverageListPaginationRejectsInvalidContracts(t *testing.T) {
tests := []struct {
name string
response string
args []string
}{
{name: "missing hasMore", response: `{"apps":[],"nextCursor":""}`},
{name: "wrong hasMore type", response: `{"apps":[],"hasMore":"false","nextCursor":""}`},
{name: "non-terminal missing continuation", response: `{"apps":[],"hasMore":true}`},
{name: "empty continuation", response: `{"apps":[],"hasMore":true,"nextCursor":""}`},
{name: "wrong continuation type", response: `{"apps":[],"hasMore":true,"nextCursor":7}`},
{name: "terminal continuation wrong type", response: `{"apps":[],"hasMore":false,"nextCursor":7}`},
{name: "stalled cursor", response: `{"apps":[],"hasMore":true,"nextCursor":"same"}`, args: []string{"--cursor", "same"}},
{name: "null page", response: `null`},
{name: "apps missing", response: `{"hasMore":false,"nextCursor":""}`},
{name: "apps wrong type", response: `{"apps":"not-a-list","hasMore":false,"nextCursor":""}`},
{name: "null app item", response: `{"apps":[null],"hasMore":false,"nextCursor":""}`},
{name: "string app item", response: `{"apps":["bad"],"hasMore":false,"nextCursor":""}`},
{name: "number app item", response: `{"apps":[7],"hasMore":false,"nextCursor":""}`},
{name: "boolean app item", response: `{"apps":[true],"hasMore":false,"nextCursor":""}`},
{name: "nested array app item", response: `{"apps":[[]],"hasMore":false,"nextCursor":""}`},
{name: "empty app object", response: `{"apps":[{}],"hasMore":false,"nextCursor":""}`},
{name: "unknown-only app object", response: `{"apps":[{"unknown":"value"}],"hasMore":false,"nextCursor":""}`},
{name: "mixed valid and invalid app items", response: `{
"apps":[{"unifiedAppId":"would-be-partial"},"bad"],
"hasMore":false,"nextCursor":""
}`},
{name: "ambiguous list fields", response: `{"apps":[],"items":[],"hasMore":false,"nextCursor":""}`},
{name: "ambiguous page envelopes", response: `{
"apps":[],"hasMore":false,"nextCursor":"",
"result":{"apps":[],"hasMore":false,"nextCursor":""}
}`},
{name: "metadata split across envelopes", response: `{
"hasMore":false,"nextCursor":"",
"result":{"apps":[]}
}`},
{name: "unknown payload wrapper conflicts with top page", response: `{
"apps":[],"hasMore":false,"nextCursor":"",
"payload":{"apps":[],"hasMore":false,"nextCursor":""}
}`},
{name: "unknown response wrapper conflicts with top page", response: `{
"apps":[],"hasMore":false,"nextCursor":"",
"response":{"items":[],"hasMore":false,"nextCursor":""}
}`},
{name: "unknown wrapper nextCursor evidence conflicts with top page", response: `{
"apps":[],"hasMore":false,"nextCursor":"",
"metadata":{"nextCursor":"hidden"}
}`},
{name: "unknown wrapper recursively nested list conflicts with top page", response: `{
"apps":[],"hasMore":false,"nextCursor":"",
"metadata":{"data":{"items":[]}}
}`},
{name: "unknown wrapper invalid apps evidence conflicts with top page", response: `{
"apps":[],"hasMore":false,"nextCursor":"",
"metadata":{"apps":"invalid"}
}`},
{name: "unsupported second-level envelope", response: `{
"result":{"data":{"apps":[],"hasMore":false,"nextCursor":""}}
}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
args := append(append([]string(nil), test.args...), "--format", "json")
output, err, caller := executeListPagination(t, test.response, args...)
if err == nil {
t.Fatalf("invalid response unexpectedly succeeded: %s", test.response)
}
if output != "" {
t.Fatalf("invalid response wrote success output: %q", output)
}
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v, want one bounded read", caller.calls)
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T %v, want structured API error", err, err)
}
if typed.Category != apperrors.CategoryAPI || typed.ExitCode() != 1 || typed.Reason != "devapp_pagination_contract_invalid" {
t.Fatalf("error contract = %#v", typed)
}
})
}
}