Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b771969cbe |
@@ -13,6 +13,13 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
targets to the initial card-creation request and prepending its returned
|
||||
`atTag` to the automatic streaming update.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed `devapp +list` pagination** (#917) — preserves `hasMore` and
|
||||
`nextCursor` in every single-page result, normalizes supported terminal
|
||||
cursor forms, and rejects malformed or ambiguous pagination responses rather
|
||||
than emitting terminal-looking partial data.
|
||||
|
||||
## [1.0.58-beta.2] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// 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 executeDevAppCommand(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 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 := executeDevAppCommand(t, "+list", "--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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppNonTargetShortcutsMockUnchanged(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
command string
|
||||
want string
|
||||
}{
|
||||
{name: "permissions", command: "+permission-list", want: "{\n \"count\": 0,\n \"permissions\": []\n}\n"},
|
||||
{name: "events", command: "+event-list", want: "{\n \"count\": 0,\n \"events\": []\n}\n"},
|
||||
{name: "versions", command: "+version-list", want: "{\n \"count\": 0,\n \"versions\": []\n}\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
stdout, stderr, err := executeDevAppCommand(
|
||||
t, test.command, "--unified-app-id", "X", "--mock", "--format", "json",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("mock %s error = %v, stderr=%q", test.command, err, stderr)
|
||||
}
|
||||
if stderr != "" || stdout != test.want {
|
||||
t.Fatalf("mock %s stdout=%q stderr=%q, want stdout=%q", test.command, stdout, stderr, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type devAppListFixtureRunner struct {
|
||||
response string
|
||||
invocation *executor.Invocation
|
||||
}
|
||||
|
||||
func (r devAppListFixtureRunner) Run(
|
||||
_ context.Context,
|
||||
invocation executor.Invocation,
|
||||
) (executor.Result, error) {
|
||||
if r.invocation != nil {
|
||||
*r.invocation = invocation
|
||||
}
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{
|
||||
"content": []any{map[string]any{"type": "text", "text": r.response}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevAppListCursorKeepsCurrentMainTrimSemantics(t *testing.T) {
|
||||
helpers.InitDepsForTest(t, helpers.GetCaller())
|
||||
var invocation executor.Invocation
|
||||
testseam.Swap(t, &rootNewCommandRunnerWithFlags, func(*GlobalFlags) executor.Runner {
|
||||
return devAppListFixtureRunner{
|
||||
response: `{"apps":[],"hasMore":true,"nextCursor":"cursor-1"}`,
|
||||
invocation: &invocation,
|
||||
}
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"devapp", "+list", "--cursor", " cursor-1 ", "--format", "json"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("trimmed stalled cursor unexpectedly succeeded")
|
||||
}
|
||||
if invocation.Params["cursor"] != "cursor-1" {
|
||||
t.Fatalf("forwarded cursor = %#v, want current-main trimmed value", invocation.Params["cursor"])
|
||||
}
|
||||
if invocation.Params["pageSize"] != 20 {
|
||||
t.Fatalf("pageSize = %#v, want 20", invocation.Params["pageSize"])
|
||||
}
|
||||
}
|
||||
|
||||
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: "list_dev_app_events"},
|
||||
{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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -589,13 +589,21 @@ 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" && invocation.Tool == "list_dev_app" {
|
||||
result = map[string]any{
|
||||
"apps": []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,
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -106,6 +107,10 @@ var ListApp = shortcut.Shortcut{
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
applyCursor(rt, params)
|
||||
requestCursor := ""
|
||||
if rt.Changed("cursor") {
|
||||
requestCursor = rt.Str("cursor")
|
||||
}
|
||||
if rt.Changed("name") {
|
||||
params["name"] = rt.Str("name")
|
||||
}
|
||||
@@ -137,23 +142,212 @@ 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", "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 app-list envelope. The app list and pagination facts
|
||||
// must come from the same map so a partial response cannot look terminal.
|
||||
func listAppParsePage(data map[string]any, requestCursor string) (listAppPage, error) {
|
||||
candidates, invalid := listAppPageCandidates(data)
|
||||
if invalid || len(candidates) != 1 {
|
||||
return listAppPage{}, listAppPaginationContractError()
|
||||
}
|
||||
|
||||
candidate := candidates[0]
|
||||
hasMoreValue, ok := candidate.envelope["hasMore"]
|
||||
if !ok {
|
||||
return listAppPage{}, listAppPaginationContractError()
|
||||
}
|
||||
hasMore, ok := hasMoreValue.(bool)
|
||||
if !ok {
|
||||
return listAppPage{}, listAppPaginationContractError()
|
||||
}
|
||||
nextCursorValue, hasNextCursor := candidate.envelope["nextCursor"]
|
||||
|
||||
if hasMore {
|
||||
if !hasNextCursor {
|
||||
return listAppPage{}, listAppPaginationContractError()
|
||||
}
|
||||
nextCursor, ok := nextCursorValue.(string)
|
||||
if !ok || nextCursor == "" || nextCursor == requestCursor {
|
||||
return listAppPage{}, listAppPaginationContractError()
|
||||
}
|
||||
return listAppPage{
|
||||
apps: candidate.apps,
|
||||
hasMore: true,
|
||||
nextCursor: nextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Deployed providers use hasMore as the terminal authority. A terminal
|
||||
// nextCursor may be absent, null, empty, or the last observed string; all
|
||||
// supported forms normalize to an empty public cursor.
|
||||
if hasNextCursor && nextCursorValue != nil {
|
||||
if _, ok := nextCursorValue.(string); !ok {
|
||||
return listAppPage{}, listAppPaginationContractError()
|
||||
}
|
||||
}
|
||||
|
||||
return listAppPage{
|
||||
apps: candidate.apps,
|
||||
hasMore: false,
|
||||
nextCursor: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// listAppPageCandidates enumerates only the response map and supported maps
|
||||
// directly beneath it. Wrong-typed collection aliases, multiple list-bearing
|
||||
// maps, split metadata, or a second nested page envelope 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
|
||||
}
|
||||
|
||||
func listAppPaginationContractError() error {
|
||||
return apperrors.NewAPI(
|
||||
"list_dev_app 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 +368,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.
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func TestListAppParsePageCollectionAliases(t *testing.T) {
|
||||
for _, alias := range []string{"list", "items", "apps", "appList", "result", "data"} {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
page, err := listAppParsePage(map[string]any{
|
||||
alias: []any{map[string]any{"name": alias}},
|
||||
"hasMore": false,
|
||||
"nextCursor": nil,
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s alias: %v", alias, err)
|
||||
}
|
||||
if page.hasMore || page.nextCursor != "" || len(page.apps) != 1 {
|
||||
t.Fatalf("page for %s = %#v", alias, page)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAppParsePageNestedEnvelope(t *testing.T) {
|
||||
page, err := listAppParsePage(map[string]any{
|
||||
"result": map[string]any{
|
||||
"apps": []any{map[string]any{"name": "nested"}},
|
||||
"hasMore": true,
|
||||
"nextCursor": "cursor-2",
|
||||
},
|
||||
}, "cursor-1")
|
||||
if err != nil {
|
||||
t.Fatalf("parse nested page: %v", err)
|
||||
}
|
||||
if !page.hasMore || page.nextCursor != "cursor-2" || len(page.apps) != 1 {
|
||||
t.Fatalf("nested page = %#v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAppParsePageTerminalCursorVariants(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
includeNext bool
|
||||
nextCursor any
|
||||
}{
|
||||
{name: "missing"},
|
||||
{name: "null", includeNext: true, nextCursor: nil},
|
||||
{name: "empty", includeNext: true, nextCursor: ""},
|
||||
{name: "last observed", includeNext: true, nextCursor: "cursor-1"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
response := map[string]any{
|
||||
"apps": []any{},
|
||||
"hasMore": false,
|
||||
}
|
||||
if test.includeNext {
|
||||
response["nextCursor"] = test.nextCursor
|
||||
}
|
||||
page, err := listAppParsePage(response, "cursor-1")
|
||||
if err != nil {
|
||||
t.Fatalf("parse terminal variant: %v", err)
|
||||
}
|
||||
if page.hasMore || page.nextCursor != "" || len(page.apps) != 0 {
|
||||
t.Fatalf("terminal page = %#v", page)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAppParsePageNonTerminalPreservesCursor(t *testing.T) {
|
||||
page, err := listAppParsePage(map[string]any{
|
||||
"apps": []any{},
|
||||
"hasMore": true,
|
||||
"nextCursor": " opaque-cursor ",
|
||||
}, "request-cursor")
|
||||
if err != nil {
|
||||
t.Fatalf("parse non-terminal page: %v", err)
|
||||
}
|
||||
if !page.hasMore || page.nextCursor != " opaque-cursor " {
|
||||
t.Fatalf("non-terminal page = %#v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAppParsePageRejectsInvalidContracts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response map[string]any
|
||||
requestCursor string
|
||||
}{
|
||||
{name: "nil response", response: nil},
|
||||
{name: "missing list", response: map[string]any{"hasMore": false}},
|
||||
{name: "missing hasMore", response: map[string]any{"apps": []any{}}},
|
||||
{name: "hasMore wrong type", response: map[string]any{"apps": []any{}, "hasMore": "false"}},
|
||||
{name: "non-terminal missing cursor", response: map[string]any{"apps": []any{}, "hasMore": true}},
|
||||
{name: "non-terminal null cursor", response: map[string]any{"apps": []any{}, "hasMore": true, "nextCursor": nil}},
|
||||
{name: "non-terminal empty cursor", response: map[string]any{"apps": []any{}, "hasMore": true, "nextCursor": ""}},
|
||||
{name: "non-terminal stalled cursor", response: map[string]any{"apps": []any{}, "hasMore": true, "nextCursor": "cursor-1"}, requestCursor: "cursor-1"},
|
||||
{name: "terminal wrong cursor type", response: map[string]any{"apps": []any{}, "hasMore": false, "nextCursor": 1}},
|
||||
{name: "wrong list type", response: map[string]any{"apps": "bad", "hasMore": false}},
|
||||
{name: "multiple lists", response: map[string]any{"apps": []any{}, "items": []any{}, "hasMore": false}},
|
||||
{name: "split metadata", response: map[string]any{
|
||||
"apps": []any{},
|
||||
"result": map[string]any{"hasMore": false},
|
||||
}},
|
||||
{name: "second nested envelope", response: map[string]any{
|
||||
"result": map[string]any{
|
||||
"data": map[string]any{"apps": []any{}, "hasMore": false},
|
||||
},
|
||||
}},
|
||||
{name: "non-list permissions alias", response: map[string]any{"permissions": []any{}, "hasMore": false}},
|
||||
{name: "non-list events alias", response: map[string]any{"events": []any{}, "hasMore": false}},
|
||||
{name: "non-list versions alias", response: map[string]any{"versions": []any{}, "hasMore": false}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := listAppParsePage(test.response, test.requestCursor)
|
||||
assertListAppPaginationError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAppProjectPreservesAliasesOrderAndCount(t *testing.T) {
|
||||
raw := []any{
|
||||
map[string]any{
|
||||
"unified_app_id": "u-1",
|
||||
"appName": "first",
|
||||
"clientId": "key-1",
|
||||
"agent_id": 1,
|
||||
"appStatus": "ONLINE",
|
||||
"modifyTime": "t-1",
|
||||
"unknown": "ignored",
|
||||
},
|
||||
map[string]any{
|
||||
"unifiedAppId": "u-2",
|
||||
"name": "second",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := listAppProject(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("project valid items: %v", err)
|
||||
}
|
||||
want := []map[string]any{
|
||||
{
|
||||
"unifiedAppId": "u-1",
|
||||
"name": "first",
|
||||
"appKey": "key-1",
|
||||
"agentId": 1,
|
||||
"status": "ONLINE",
|
||||
"gmtModified": "t-1",
|
||||
},
|
||||
{"unifiedAppId": "u-2", "name": "second"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("projection = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAppProjectRejectsUnprojectableItems(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
raw []any
|
||||
}{
|
||||
{name: "non object", raw: []any{"bad"}},
|
||||
{name: "empty object", raw: []any{map[string]any{}}},
|
||||
{name: "unknown only", raw: []any{map[string]any{"unknown": true}}},
|
||||
{name: "mixed invalid", raw: []any{map[string]any{"name": "valid"}, "bad"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
projected, err := listAppProject(test.raw)
|
||||
if projected != nil {
|
||||
t.Fatalf("partial projection leaked = %#v", projected)
|
||||
}
|
||||
assertListAppPaginationError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonTargetListAliasesRemainCompatible(t *testing.T) {
|
||||
permissionItem := map[string]any{"scopeValue": "scope"}
|
||||
for _, alias := range []string{"permissionList", "scopes"} {
|
||||
got := permissionListProject(map[string]any{alias: []any{permissionItem}})
|
||||
if len(got) != 1 || got[0]["scopeValue"] != "scope" {
|
||||
t.Fatalf("permission alias %s regressed: %#v", alias, got)
|
||||
}
|
||||
}
|
||||
|
||||
events := eventListProject(map[string]any{
|
||||
"eventList": []any{map[string]any{"eventCode": "event"}},
|
||||
})
|
||||
if len(events) != 1 || events[0]["eventCode"] != "event" {
|
||||
t.Fatalf("eventList alias regressed: %#v", events)
|
||||
}
|
||||
|
||||
versions := versionListProject(map[string]any{
|
||||
"versionList": []any{map[string]any{"versionId": "version"}},
|
||||
})
|
||||
if len(versions) != 1 || versions[0]["versionId"] != "version" {
|
||||
t.Fatalf("versionList alias regressed: %#v", versions)
|
||||
}
|
||||
}
|
||||
|
||||
func assertListAppPaginationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected pagination contract error")
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) {
|
||||
t.Fatalf("error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if structured.Category != apperrors.CategoryAPI ||
|
||||
structured.Reason != "devapp_pagination_contract_invalid" ||
|
||||
structured.ExitCode() != 1 {
|
||||
t.Fatalf("pagination error = %#v", structured)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user