chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsAccessScopeGetDryRun pins URL shape and --app-id requirement for the
// read-side companion of +access-scope-set. Response passthrough (scope enum,
// split user/department/chat arrays) is covered by unit tests in shortcuts/apps.
func TestAppsAccessScopeGetDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("HappyPath", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-get",
"--app-id", "app_x",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/access-scope", gjson.Get(result.Stdout, "api.0.url").String())
// GET request: no body and no query params.
assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.params").Exists())
})
t.Run("RejectsMissingAppID", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+access-scope-get", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), `required flag(s) "app-id" not set`)
})
}
@@ -0,0 +1,193 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsAccessScopeSetDryRun pins the user-facing scope-string -> server-enum
// mapping (public->All, tenant->Tenant, specific->Range) and the three-way
// mutex between specific / public / tenant.
func TestAppsAccessScopeSetDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("SpecificMapsToRange", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_x"},{"type":"chat","id":"oc_x"}]`,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "PUT", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/access-scope", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "Range", gjson.Get(result.Stdout, "api.0.body.scope").String())
assert.Equal(t, "ou_x", gjson.Get(result.Stdout, "api.0.body.users.0").String())
assert.Equal(t, "oc_x", gjson.Get(result.Stdout, "api.0.body.chats.0").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.departments").Exists(),
"empty department list must be omitted")
assert.False(t, gjson.Get(result.Stdout, "api.0.body.apply_config").Exists())
})
t.Run("SpecificWithApplyConfig", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_x"}]`,
"--apply-enabled",
"--approver", "ou_y",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.True(t, gjson.Get(result.Stdout, "api.0.body.apply_config.enabled").Bool())
assert.Equal(t, "ou_y", gjson.Get(result.Stdout, "api.0.body.apply_config.approvers.0").String())
})
t.Run("PublicMapsToAll", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "public",
"--require-login=false",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "All", gjson.Get(result.Stdout, "api.0.body.scope").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.require_login").Bool())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.users").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.apply_config").Exists())
})
t.Run("TenantMapsToTenant", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "tenant",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Tenant", gjson.Get(result.Stdout, "api.0.body.scope").String())
// scope is the only body field in tenant mode.
assert.False(t, gjson.Get(result.Stdout, "api.0.body.require_login").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.users").Exists())
})
t.Run("RejectsSpecificMissingTargets", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "--targets is required")
})
t.Run("RejectsTenantWithExtraFlags", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "tenant",
"--targets", `[]`,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "no extra flags allowed")
})
t.Run("RejectsBadTargetType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"group","id":"oc_x"}]`,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "must be one of")
})
t.Run("RejectsApproverWithoutApplyEnabled", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_x"}]`,
"--approver", "ou_y",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "--apply-enabled")
})
}
@@ -0,0 +1,168 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsCreateDryRun pins the request shape and Validate behavior for
// `apps +create`. The shortcut is UAT-only and posts to the registered
// /open-apis/spark/v1 namespace; both are checked here.
func TestAppsCreateDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("HappyPath_HTMLAppType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", "Demo",
"--app-type", "html",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.body.app_type").String())
// Optional fields stay omitted when not provided.
assert.False(t, gjson.Get(result.Stdout, "api.0.body.description").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.icon_url").Exists())
})
t.Run("AllFields", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", "Demo",
"--app-type", "html",
"--description", "survey app",
"--icon-url", "https://example.com/icon.svg",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.body.app_type").String())
assert.Equal(t, "survey app", gjson.Get(result.Stdout, "api.0.body.description").String())
assert.Equal(t, "https://example.com/icon.svg", gjson.Get(result.Stdout, "api.0.body.icon_url").String())
})
t.Run("RejectsMissingName", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--app-type", "html",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), `required flag(s) "name" not set`)
})
t.Run("RejectsBlankName", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", " ",
"--app-type", "html",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateErrorMessage(result)
assert.Contains(t, msg, "--name is required")
})
t.Run("RejectsMissingAppType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", "Demo",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), `required flag(s) "app-type" not set`)
})
t.Run("RejectsInvalidAppType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", "Demo",
"--app-type", "spa",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateErrorMessage(result)
assert.Contains(t, msg, "invalid value")
assert.Contains(t, msg, "full_stack")
})
t.Run("RejectsLegacyUppercaseAppType", func(t *testing.T) {
// --app-type is a strict lowercase enum (html / full_stack); the CLI does
// not normalize case. Legacy uppercase "HTML" is rejected — backend
// compatibility for legacy values is a server concern the client does not
// surface.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", "Demo",
"--app-type", "HTML",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateErrorMessage(result)
assert.Contains(t, msg, "invalid value")
assert.Contains(t, msg, "HTML")
})
}
@@ -0,0 +1,50 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsDBEnvCreateDryRun pins +db-env-create URL `/apps/{app_id}/db_dev_init` 和 sync_data body 透传。
// Risk: high-risk-write 在 dry-run 下不需要 --yes 确认。
func TestAppsDBEnvCreateDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultSyncDataFalse", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--environment", "dev", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/db_dev_init", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "false", gjson.Get(result.Stdout, "api.0.body.sync_data").String())
})
t.Run("SyncDataTrue", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "true", gjson.Get(result.Stdout, "api.0.body.sync_data").String())
})
}
@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsDBExecuteDryRun pins +db-execute 复用存量 URLCLI 永远走 DBA 模式
// ?transactional=false),sql body 由 --sql 透传,默认不传 env(空值,由服务端按 workspace 定分支)。
func TestAppsDBExecuteDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultEnvUnsetAndTransactionalFalse", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/sql_commands", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "SELECT 1", gjson.Get(result.Stdout, "api.0.body.sql").String())
assert.Equal(t, "false", gjson.Get(result.Stdout, "api.0.params.transactional").String(),
"CLI is DBA mode → must send transactional=false in query")
assert.False(t, gjson.Get(result.Stdout, "api.0.body.transactional").Exists(),
"transactional should be in query, not body")
assert.False(t, gjson.Get(result.Stdout, "api.0.params.env").Exists(),
"default: no --environment → env key must be omitted (server picks workspace default branch)")
})
t.Run("OnlineEnvSwitch", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--environment", "online", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.params.env").String())
})
t.Run("RejectsEmptySQL", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", " ", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "empty --sql must fail validation")
})
}
@@ -0,0 +1,82 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsDBTableGetDryRun pins +db-table-get 复用存量 URL。
// 没有独立 --ddl flag —— 由 --format 同时驱动 CLI 渲染和 server 请求形态:
//
// --format pretty → CLI 给 server 带 ?format=ddl
// --format json / table / ndjson / csv(含默认)→ CLI 不传 format query
func TestAppsDBTableGetDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultFormatJSONOmitsFormatQuery", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-table-get", "--app-id", "app_x", "--table", "orders", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables/orders", gjson.Get(result.Stdout, "api.0.url").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.format").Exists(),
"default (json) should omit format query")
})
t.Run("PrettyFormatSendsFormatDDL", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// pretty 模式 dry-run 输出是 plain text 列表(非 JSON envelope),用 substring 校验 query。
assert.Contains(t, result.Stdout, "format=ddl",
"--format pretty must trigger ?format=ddl")
})
t.Run("TableFormatOmitsFormatQuery", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "table", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.False(t, gjson.Get(result.Stdout, "api.0.params.format").Exists())
})
t.Run("RequiresTableFlag", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-table-get", "--app-id", "app_x", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "missing --table must fail")
})
}
@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsDBTableListDryRun pins +db-table-list 复用存量 URL/apps/{app_id}/tables
// 不带 /db/),cursor 分页参数与 env 透传,且不发 include_stats query。
func TestAppsDBTableListDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultsToNoEnvAndPageSize20", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-table-list", "--app-id", "app_x", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.env").Exists(),
"default: no --environment → env key must be omitted (server picks workspace default branch)")
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
"empty page_token must be omitted")
assert.False(t, gjson.Get(result.Stdout, "api.0.params.include_stats").Exists(),
"CLI should not send include_stats query (server returns stats by default)")
})
t.Run("CustomPaginationAndDevEnv", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-table-list",
"--app-id", "app_x", "--environment", "dev",
"--page-size", "50", "--page-token", "cursor-abc",
"--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String())
assert.Equal(t, "50", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.Equal(t, "cursor-abc", gjson.Get(result.Stdout, "api.0.params.page_token").String())
})
t.Run("RejectsBlankAppID", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+db-table-list", "--app-id", " ", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "blank app-id must fail validation")
assert.Contains(t, validateErrorMessage(result), "app-id")
})
}
@@ -0,0 +1,82 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"path/filepath"
"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 TestAppsEnvPullDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultPath", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+env-pull",
"--app-id", "app_x",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.include_values").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.params").Exists())
assert.True(t, gjson.Get(result.Stdout, "project_path").Exists())
assert.Contains(t, gjson.Get(result.Stdout, "env_file").String(), ".env.local")
assert.False(t, gjson.Get(result.Stdout, "env_keys").Exists())
})
t.Run("CustomProjectPath", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
projectDir := filepath.Join(t.TempDir(), "demo")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+env-pull",
"--app-id", "app_x",
"--project-path", projectDir,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, projectDir, gjson.Get(result.Stdout, "project_path").String())
assert.Equal(t, filepath.Join(projectDir, ".env.local"), gjson.Get(result.Stdout, "env_file").String())
})
t.Run("MissingAppID", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+env-pull",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, result.Stdout+result.Stderr, `--app-id is required`)
})
}
@@ -0,0 +1,93 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"path/filepath"
"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 TestAppsGitCredentialInitDryRun(t *testing.T) {
setAppsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+git-credential-init",
"--app-id", "app_xxx",
"--dry-run",
},
BinaryPath: "../../../lark-cli",
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_xxx/git_info", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "api.0.params.app_id").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists())
assert.Equal(t, "api-plus-local-setup", gjson.Get(result.Stdout, "mode").String())
assert.Equal(t, "initialize_local_git_credential", gjson.Get(result.Stdout, "action").String())
assert.True(t, strings.HasSuffix(gjson.Get(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json")))
assert.Equal(t, int64(3), gjson.Get(result.Stdout, "local_effects.#").Int())
assert.Equal(t, "save the issued PAT in the local system credential store", gjson.Get(result.Stdout, "local_effects.0").String())
assert.Equal(t, "write app-scoped git credential metadata", gjson.Get(result.Stdout, "local_effects.1").String())
assert.Equal(t, "configure a URL-scoped Git credential helper in global git config when possible", gjson.Get(result.Stdout, "local_effects.2").String())
}
func TestAppsGitCredentialListDryRun(t *testing.T) {
setAppsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+git-credential-list", "--dry-run"},
BinaryPath: "../../../lark-cli",
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Preview local Git credential listing (no API call, read-only local state).", gjson.Get(result.Stdout, "description").String())
assert.Equal(t, "local-read-only", gjson.Get(result.Stdout, "mode").String())
assert.Equal(t, "list_local_git_credentials", gjson.Get(result.Stdout, "action").String())
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int())
assert.Contains(t, gjson.Get(result.Stdout, "storage_root").String(), filepath.Join("", "spark"))
assert.Equal(t, "scan app-scoped git credential metadata under the CLI config directory", gjson.Get(result.Stdout, "reads.0").String())
}
func TestAppsGitCredentialRemoveDryRun(t *testing.T) {
setAppsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+git-credential-remove", "--app-id", "app_xxx", "--dry-run"},
BinaryPath: "../../../lark-cli",
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Preview local Git credential cleanup (no API call; would clean up local-only state).", gjson.Get(result.Stdout, "description").String())
assert.Equal(t, "local-cleanup-only", gjson.Get(result.Stdout, "mode").String())
assert.Equal(t, "remove_local_git_credential", gjson.Get(result.Stdout, "action").String())
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "app_id").String())
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int())
assert.True(t, strings.HasSuffix(gjson.Get(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json")))
assert.Equal(t, "read app-scoped git credential metadata", gjson.Get(result.Stdout, "effects.0").String())
}
@@ -0,0 +1,122 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"net/url"
"os"
"path/filepath"
"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 TestAppsGitCredentialListLocalE2E(t *testing.T) {
env := setupAppsGitCredentialLocalEnv(t)
seedAppsGitCredentialMetadata(t, env.configDir, "app_a", "https://example.com/git/u/a.git", "pat-ref-a")
seedAppsGitCredentialMetadata(t, env.configDir, "app_b", "https://example.com/git/u/b.git", "pat-ref-b")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+git-credential-list"},
DefaultAs: "user",
Env: env.vars,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "data.count").Int())
credentials := map[string]gjson.Result{}
for _, credential := range gjson.Get(result.Stdout, "data.credentials").Array() {
credentials[credential.Get("app_id").String()] = credential
}
require.Contains(t, credentials, "app_a")
require.Contains(t, credentials, "app_b")
assert.Equal(t, "https://example.com/git/u/a.git", credentials["app_a"].Get("repository_url").String())
assert.Equal(t, "missing_secret", credentials["app_a"].Get("status").String())
assert.Equal(t, "https://example.com/git/u/b.git", credentials["app_b"].Get("repository_url").String())
assert.Equal(t, "missing_secret", credentials["app_b"].Get("status").String())
assert.False(t, credentials["app_a"].Get("expires_at").Exists())
assert.False(t, credentials["app_a"].Get("expired").Exists())
}
func TestAppsGitCredentialRemoveLocalE2E(t *testing.T) {
env := setupAppsGitCredentialLocalEnv(t)
metadataPath := seedAppsGitCredentialMetadata(t, env.configDir, "app_xxx", "https://example.com/git/u/app.git", "pat-ref-remove")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+git-credential-remove", "--app-id", "app_xxx"},
DefaultAs: "user",
Env: env.vars,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "data.app_id").String())
assert.True(t, gjson.Get(result.Stdout, "data.removed").Bool())
assert.NoFileExists(t, metadataPath)
}
type appsGitCredentialLocalEnv struct {
configDir string
vars map[string]string
}
func setupAppsGitCredentialLocalEnv(t *testing.T) appsGitCredentialLocalEnv {
t.Helper()
configDir := t.TempDir()
homeDir := t.TempDir()
gitConfig := filepath.Join(t.TempDir(), ".gitconfig")
return appsGitCredentialLocalEnv{
configDir: configDir,
vars: map[string]string{
"LARKSUITE_CLI_CONFIG_DIR": configDir,
"LARKSUITE_CLI_APP_ID": "apps_local_test",
"LARKSUITE_CLI_APP_SECRET": "apps_local_secret",
"LARKSUITE_CLI_BRAND": "feishu",
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1",
"LARKSUITE_CLI_DATA_DIR": filepath.Join(homeDir, ".local", "share"),
"HOME": homeDir,
"GIT_CONFIG_GLOBAL": gitConfig,
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_TERMINAL_PROMPT": "0",
},
}
}
func seedAppsGitCredentialMetadata(t *testing.T, configDir, appID, gitHTTPURL, patRef string) string {
t.Helper()
dir := filepath.Join(configDir, "spark", url.PathEscape(appID))
require.NoError(t, os.MkdirAll(dir, 0700))
path := filepath.Join(dir, "git.json")
payload := map[string]any{
"version": 1,
"app_id": appID,
"git_http_url": gitHTTPURL,
"profile": "default",
"profile_app_id": "apps_local_test",
"user_open_id": "ou_local_test",
"username": "x-access-token",
"pat_ref": patRef,
"status": "confirmed",
"expires_at": time.Now().Add(24 * time.Hour).Unix(),
"updated_at": time.Now().Unix(),
}
data, err := json.MarshalIndent(payload, "", " ")
require.NoError(t, err)
require.NoError(t, os.WriteFile(path, append(data, '\n'), 0600))
return path
}
@@ -0,0 +1,326 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsHTMLPublishDryRun exercises the walker / manifest layer without
// packing or uploading. --path goes through LocalFileIO which bounds reads to
// the runtime cwd, so each sub-test seeds fixtures in a t.TempDir and runs
// the binary with WorkDir set to that dir + relative --path.
//
// Hidden files are intentionally included — the walker is deliberately not
// filtering, so the manifest must reflect everything the user pointed --path
// at. Users are documented to pass clean build output directories (e.g.
// ./dist), not source trees.
func TestAppsHTMLPublishDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("Directory_ReportsManifest", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html><body>hi</body></html>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "logo.svg"), []byte("<svg/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code", gjson.Get(result.Stdout, "api.0.url").String())
// file_count / files / total_size_bytes sit at envelope top level
// (not under api.0.body — manifest is dry-run metadata, not the HTTP body).
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "file_count").Int())
assert.Greater(t, gjson.Get(result.Stdout, "total_size_bytes").Int(), int64(0))
files := gjson.Get(result.Stdout, "files").Array()
require.Len(t, files, 2)
names := []string{files[0].String(), files[1].String()}
assert.Contains(t, names, "index.html")
assert.Contains(t, names, "logo.svg")
})
t.Run("SingleFile_OneEntry", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "page.html"), []byte("<html></html>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "page.html",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, "page.html", gjson.Get(result.Stdout, "files.0").String())
})
t.Run("HiddenFilesIncludedExceptGit", func(t *testing.T) {
// The walker filters the .git directory (and a .git gitdir pointer file)
// so a stray repo under --path doesn't ship its history / remote URL to a
// public share URL. Generic hidden files like .DS_Store are NOT filtered —
// only .git is — so users still see everything else they pointed --path at.
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".DS_Store"), []byte("noise"), 0o644))
require.NoError(t, os.Mkdir(filepath.Join(dir, "dist", ".git"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// index.html + .DS_Store kept; .git/HEAD filtered out → 2 files.
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "file_count").Int(),
"walker must keep non-.git hidden files but drop .git; got: %s", result.Stdout)
names := gjson.Get(result.Stdout, "files").Array()
var got []string
for _, n := range names {
got = append(got, n.String())
}
assert.Contains(t, got, "index.html")
assert.Contains(t, got, ".DS_Store")
assert.NotContains(t, got, ".git/HEAD", "walker must exclude .git contents")
})
t.Run("EmptyDir_ManifestEmpty", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "total_size_bytes").Int())
assert.Contains(t, gjson.Get(result.Stdout, "validation_error").String(), "index.html",
"empty dir should report index.html validation_error: %s", result.Stdout)
})
t.Run("MissingIndexHTML_SurfacesValidationError", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "page.html"), []byte("<html/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, "page.html", gjson.Get(result.Stdout, "files.0").String())
assert.Contains(t, gjson.Get(result.Stdout, "validation_error").String(), "index.html")
})
t.Run("RejectsMissingAppID", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), `required flag(s) "app-id" not set`)
})
t.Run("RejectsMissingPath", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), `required flag(s) "path" not set`)
})
t.Run("RejectsSensitivePathsByDefault", func(t *testing.T) {
// Validate scans candidates for well-known credential files and rejects
// when any are found. Dry-run also fails (Validate runs before the
// dry-run branch) — that's the point: dry-run preview matches Execute.
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".env"), []byte("SECRET=xxx\n"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), ".env",
"validation error should name the offending file: %s", result.Stderr)
})
t.Run("AllowSensitiveOverride", func(t *testing.T) {
// --allow-sensitive bypasses the credential-file gate (legitimate
// cases: docs site shipping example .env files). Dry-run output
// surfaces a sensitive_waived field so the caller still sees what
// was let through.
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".env.example"), []byte("API_KEY=replace-me\n"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--allow-sensitive",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
waived := gjson.Get(result.Stdout, "sensitive_waived").Array()
require.Len(t, waived, 1, "expected sensitive_waived to list the file, got: %s", result.Stdout)
assert.Equal(t, ".env.example", waived[0].String())
})
t.Run("CleanCwdAllowed", func(t *testing.T) {
// --path "." is no longer hard-rejected. A cwd that doesn't contain
// well-known credential files is a valid publish target.
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", ".",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int())
})
t.Run("TrimsAppIDAndPath", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", " app_x ",
"--path", " ./dist ",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int(),
"path trimming must produce the same manifest as untrimmed input")
})
}
+139
View File
@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsListDryRun pins cursor-pagination params: default page_size=20 is
// always written; empty --page-token is omitted; negative page_size is passed
// through unchanged (server is the source of truth for range validation).
func TestAppsListDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultPageSize", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
"empty page_token must be omitted")
})
t.Run("CustomPageSize", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--page-size", "50", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "50", gjson.Get(result.Stdout, "api.0.params.page_size").String())
})
t.Run("WithPageToken", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--page-token", "cursor_abc", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "cursor_abc", gjson.Get(result.Stdout, "api.0.params.page_token").String())
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
})
t.Run("WithKeywordOwnershipAppType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list",
"--keyword", "survey", "--ownership", "mine", "--app-type", "html",
"--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "survey", gjson.Get(result.Stdout, "api.0.params.keyword").String())
assert.Equal(t, "mine", gjson.Get(result.Stdout, "api.0.params.ownership").String())
assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.params.app_type").String())
})
t.Run("OmitsEmptyFilters", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
for _, p := range []string{"keyword", "ownership", "app_type"} {
assert.False(t, gjson.Get(result.Stdout, "api.0.params."+p).Exists(),
"empty %s must be omitted", p)
}
})
t.Run("RejectsInvalidOwnership", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--ownership", "bogus", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "invalid --ownership enum must be rejected")
})
t.Run("RejectsLegacyUppercaseAppType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--app-type", "HTML", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "legacy uppercase --app-type must be rejected")
})
t.Run("NegativePageSizePassesThrough", func(t *testing.T) {
// By design CLI does not bound page_size; server validates. Test pins that
// invariant so a well-meaning client-side check doesn't sneak in.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--page-size", "-1", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "-1", gjson.Get(result.Stdout, "api.0.params.page_size").String())
})
}
@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsUpdateDryRun pins partial-update semantics: PATCH with only the
// fields the user supplied; --app-id and at-least-one-field are both required.
func TestAppsUpdateDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("PartialFieldsName", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+update",
"--app-id", "app_x",
"--name", "v2",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "PATCH", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "v2", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.description").Exists(),
"description must be omitted when not provided")
})
t.Run("WithDescription", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+update",
"--app-id", "app_x",
"--name", "v2",
"--description", "updated",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "v2", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "updated", gjson.Get(result.Stdout, "api.0.body.description").String())
})
t.Run("RejectsMissingAppID", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+update",
"--name", "v2",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), `required flag(s) "app-id" not set`)
})
t.Run("RejectsNoFields", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+update",
"--app-id", "app_x",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateErrorMessage(result)
assert.Contains(t, msg, "at least one")
})
}
+35
View File
@@ -0,0 +1,35 @@
# 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)
- Local E2E coverage: 100% (2/2 local-only commands)
- Live coverage: 0%
## 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.
- `TestAppsUpdateDryRun`: partial-field PATCH semantics; `--app-id` and at-least-one-field validation.
- `TestAppsListDryRun`: default `page_size=20`; empty `--page-token` omitted; negative size passed through to server (no client-side bound check); `--keyword`/`--ownership`/`--app-type` pass-through + empty-omission; invalid `--ownership` and legacy uppercase `--app-type` enum rejection.
- `TestAppsAccessScopeSetDryRun`: CLI input `specific`/`public`/`tenant` -> server enum `Range`/`All`/`Tenant`; `apply_config.approvers` shape; four mutex rejection paths.
- `TestAppsAccessScopeGetDryRun`: URL shape; no body/params on GET; `--app-id` required.
- `TestAppsHTMLPublishDryRun`: walker manifest for directory + single file; hidden files intentionally included (design decision); empty dir / missing `index.html` produce envelope `validation_error` field (dry-run exits 0 advisory, not blocking); both required-flag rejections.
- `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.
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}`.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | apps +create | shortcut | apps_create_dryrun_test.go::TestAppsCreateDryRun | `--name`, `--app-type` (required, case-sensitive, `html`/`full_stack`), `--description`, `--icon-url` | live blocked: no +delete to clean up |
| ✓ | apps +update | shortcut | apps_update_dryrun_test.go::TestAppsUpdateDryRun | `--app-id`; at least one of `--name`/`--description` | live blocked: no +delete |
| ✓ | apps +list | shortcut | apps_list_dryrun_test.go::TestAppsListDryRun | `--keyword`; `--ownership` (enum all/mine/shared); `--app-type` (enum html/full_stack); `--page-size` default 20; `--page-token` cursor | live blocked: needs tenant fixtures |
| ✓ | apps +access-scope-set | shortcut | apps_access_scope_set_dryrun_test.go::TestAppsAccessScopeSetDryRun | `--scope specific/public/tenant`; `--targets` JSON; `--apply-enabled --approver`; `--require-login` | live blocked: needs real open_ids |
| ✓ | apps +access-scope-get | shortcut | apps_access_scope_get_dryrun_test.go::TestAppsAccessScopeGetDryRun | `--app-id` | live blocked: depends on +access-scope-set state |
| ✓ | apps +html-publish | shortcut | apps_html_publish_dryrun_test.go::TestAppsHTMLPublishDryRun | `--app-id`, `--path` (file or directory containing `index.html`) | live blocked: real upload has side effects; no rollback API |
| ✓ | 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 |
+34
View File
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/tidwall/gjson"
)
// setAppsDryRunEnv isolates config and supplies stub credentials so dry-run
// short-circuits before identity / scope resolution touches a real keychain.
// Apps shortcuts are UAT-only, so tests pass DefaultAs:"user" to the harness.
func setAppsDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "apps_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "apps_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
// validateErrorMessage extracts the structured error.message from a dry-run
// Validate-stage failure envelope. Repo convention is "stdout first, stderr
// fallback" — markdown / drive_search emit the JSON envelope to stdout (exit
// 0), apps currently emits to stderr (exit 2). Reading both orders shields
// tests from runner-internal routing changes.
func validateErrorMessage(r *clie2e.Result) string {
if msg := gjson.Get(r.Stdout, "error.message").String(); msg != "" {
return msg
}
return gjson.Get(r.Stderr, "error.message").String()
}