Compare commits

..

16 Commits

Author SHA1 Message Date
zhaojunlin.0405
5b235130fb test: use separate stderr buffers in gitArchive to avoid data race
archive.Stderr and extract.Stderr shared one strings.Builder; os/exec
spawns a stderr-copy goroutine per command, so git archive and tar (which
run concurrently) could write the builder simultaneously. strings.Builder
is not concurrency-safe, so go test -race would flag it on any run where
both processes emit stderr. Give each process its own buffer and report
the relevant one on failure. Flagged by CodeRabbit on PR #1840.
2026-07-09 20:39:36 +08:00
zhaojunlin.0405
85ccfb618a test: isolate plugin_e2e run() so all forks are deterministic offline
The previous run() inherited the host environment, so a fork's startup
metadata behavior depended on the developer machine's ~/.lark-cli cache
and live network. Tests passed locally but flaked on CI (e.g.
TestSubsystemCredentialBlock reaching a metadata fetch instead of the
credential block). Move the isolated, offline environment (fresh empty
LARKSUITE_CLI_CONFIG_DIR + LARKSUITE_CLI_REMOTE_META=off) into run()
itself and fold the per-file runEnv/runIsolated helpers into the shared
runWithEnv, so every test reproduces the bare-module customer state on
any machine, including CI. Tests needing runtime metadata still seed it
explicitly via runWithSeededCatalog.
2026-07-09 20:37:37 +08:00
zhaojunlin.0405
eee38bb96f test: make plugin_e2e forks deterministic offline; cover runtime schema catalog
Two plugin_e2e tests resolved differently on a clean CI runner than on a
developer box because they used run(), which inherits the host's ~/.lark-cli
metadata cache and network. On CI (cold cache, offline) they failed; locally
they passed.

- Replace TestSubsystemTransportAbort with TestRuntimeCatalogResolvesSchema.
  The transport round-trip it targeted is unreachable in a bare-module fork
  offline (credential resolution fails first); the transport path is covered
  end-to-end by the sidecar suite and the credential-block test. The new test
  seeds an obviously-synthetic service into a bare-module fork's on-disk cache
  and asserts `schema` resolves it from the runtime catalog -- the primary
  fix for #1764 (module builds now consult the runtime catalog instead of
  returning "Unknown service"). It complements the existing cold-cache degrade
  test, pinning both branches, and runs fully offline.
- Drop the readonly parent-group sub-case: mixed_children_policy needs a
  metadata-enumerated child tree the bare-module fork does not have, so offline
  it collapses to domain_not_allowed; that reason_code is covered by cmdpolicy
  unit tests.
- Fix errorlint in run() (errors.As), and stream `git archive` into `tar`
  through an explicit pipe instead of a shell in gitArchive.
- Set persist-credentials: false on the two new job checkouts and reuse
  assertReasonCodeEnvelope in TestReadonlyDenial.
2026-07-09 20:18:22 +08:00
zhaojunlin.0405
15eaaaeccc test: reword transport-abort comment to drop endpoint path 2026-07-09 19:08:37 +08:00
zhaojunlin.0405
407c484ef2 test: split sidecar roundtrip into readable named steps
Extract the 230-line TestSidecarHMACRoundTrip into a short top-level flow
(start stubs -> build+run fork -> assert) backed by focused helpers: the
in-test sidecar handler splits into verifyProxyRequest (steps 0-4) and
forwardWithInjectedToken; the two httptest stand-ins become mockUpstream /
inTestSidecar types sharing a requestSink; build/run/assert move into
buildAuthsidecarFork, runFork, and the two assert* helpers. Behavior unchanged.
2026-07-09 11:47:11 +08:00
zhaojunlin.0405
78f8837567 test: dedupe plugin_e2e envelope assert and fix comments
Merge the byte-identical assertInstallEnvelope into a single neutral
assertReasonCodeEnvelope shared by both policy-denial and install-time
reason_code tests. Reword the observer-panic-isolation comment to describe
the baseline-relative assertion instead of a hardcoded exit 0, and the
auditPlugin comment to say it is based on (not mirrors) the shipped example.
2026-07-08 17:43:55 +08:00
zhaojunlin.0405
1052c9cdbb ci: make plugin/sidecar integration jobs observe-only
The two new L4 jobs (plugin-integration, sidecar-integration) still run
on every PR and report status in the results summary, but their failure
no longer blocks the merge gate during the initial soak. Once they prove
stable, add them back to the results FAILED loop to make them required.
2026-07-08 16:32:37 +08:00
zhaojunlin.0405
ecded9975e ci: add sidecar-integration job and wire results gate 2026-07-08 15:40:57 +08:00
zhaojunlin.0405
d60f71054a test: add sidecar HMAC round-trip L4 and Makefile tag target 2026-07-08 15:37:10 +08:00
zhaojunlin.0405
11d1de8c2e ci: add plugin-integration job and wire results gate 2026-07-08 15:01:24 +08:00
zhaojunlin.0405
10e860ffd2 test: cover stub-metadata degrade and offline subsystem forks 2026-07-08 14:51:44 +08:00
zhaojunlin.0405
9e5bf906fd test: cover install-time reason_codes on broken plugin forks 2026-07-08 13:23:55 +08:00
zhaojunlin.0405
9ef612bc4a test: cover identity/denylist/multi-rule denial, observe and wrap in plugin_e2e 2026-07-08 13:04:03 +08:00
zhaojunlin.0405
71c4c1fda3 test: cover restrict denial, allow-path and diagnostics in plugin_e2e 2026-07-08 11:27:59 +08:00
zhaojunlin.0405
c2a1ef66ed test: add plugin_e2e L4 fork-build harness 2026-07-08 11:12:58 +08:00
zhaojunlin.0405
0c25b26d33 fix: repair authsidecar_demo server-demo test compilation 2026-07-08 10:59:29 +08:00
50 changed files with 2090 additions and 1804 deletions

View File

@@ -47,6 +47,34 @@ jobs:
exit 1
fi
plugin-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
# No fetch_meta: the git-archive clean tree must embed only the
# committed meta_data stub (reproduces the bare-module customer state).
- name: Run plugin-integration L4 tests
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
sidecar-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Run sidecar tag build + HMAC round-trip
run: make sidecar-test
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
@@ -414,7 +442,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -434,10 +462,18 @@ jobs:
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
#
# plugin-integration and sidecar-integration are intentionally NOT
# in this loop yet: they run on every PR and their status is shown
# in the table above, but a failure is observe-only (non-blocking)
# during the initial soak. Add them back here to make them required
# once they have proven stable.
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \

View File

@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
all: test
@@ -105,6 +105,14 @@ uninstall:
clean:
rm -f $(BINARY)
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
# default CI matrix never sees (they carry //go:build tags).
sidecar-test:
go build -tags authsidecar -o /dev/null .
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.

View File

@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -145,10 +145,7 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
existing := map[string]bool{}
token := ""
for {
params := map[string]interface{}{"page_size": 100}
if env != "" {
params["env"] = env
}
params := map[string]interface{}{"env": env, "page_size": 100}
if token != "" {
params["page_token"] = token
}
@@ -171,11 +168,7 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
// fetchAuditEnabledTables 拉审计状态返回当前已开启审计的表名集合status 命令同源接口)。
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
statusParams := map[string]interface{}{}
if env != "" {
statusParams["env"] = env
}
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
if err != nil {
return nil, err
}
@@ -215,10 +208,11 @@ func auditListTables(rctx *common.RuntimeContext) []string {
// buildAuditListParams 组装 audit_list 查询参数env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{
params := map[string]interface{}{
"env": dbEnv(rctx),
"tables": strings.Join(tables, ","),
"page_size": rctx.Int("page-size"),
})
}
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v

View File

@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to enable audit for", Required: true},
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Enable table audit").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Params(map[string]interface{}{"env": dbEnv(rctx)}).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
stop := rctx.StartSpinner("Enabling audit logging for " + table)
defer stop()
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
dbEnvParams(rctx, map[string]interface{}{}),
map[string]interface{}{"env": dbEnv(rctx)},
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
stop()
if err != nil {
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to disable audit for", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Disable table audit").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Params(map[string]interface{}{"env": dbEnv(rctx)}).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
}
table := strings.TrimSpace(rctx.Str("table"))
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
dbEnvParams(rctx, map[string]interface{}{}),
map[string]interface{}{"env": dbEnv(rctx)},
map[string]interface{}{"table": table, "enabled": false})
if err != nil {
return withAppsHint(err, dbAuditSetHint)

View File

@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
// buildAuditStatusParams 组装 audit_status 查询参数env 及可选 table单表查询
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{})
params := map[string]interface{}{"env": dbEnv(rctx)}
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
params["table"] = t
}

View File

@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -77,9 +77,10 @@ var AppsDBChangelogList = common.Shortcut{
// buildChangelogParams 组装 changelog_list 查询参数env / page_size 及可选 table/change_id/since/until/page_token。
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{
params := map[string]interface{}{
"env": dbEnv(rctx),
"page_size": rctx.Int("page-size"),
})
}
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v

View File

@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
{Name: "table", Desc: "source table", Required: true},
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
return common.NewDryRunAPI().
GET(appDataExportPath(appID)).
Desc("Export Miaoda app table data (raw bytes)").
Params(dbEnvParams(rctx, map[string]interface{}{
"table": strings.TrimSpace(rctx.Str("table")),
Params(map[string]interface{}{
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
"format": format, "limit": rctx.Int("limit"),
}))
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
@@ -95,18 +95,15 @@ var AppsDBDataExport = common.Shortcut{
// total 查询失败不阻断导出——回退到按导出文件内容数行。
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
exportQuery := larkcore.QueryParams{
"table": []string{table},
"format": []string{format},
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
}
if env := dbEnv(rctx); env != "" {
exportQuery["env"] = []string{env}
}
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: exportQuery,
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: larkcore.QueryParams{
"env": []string{dbEnv(rctx)},
"table": []string{table},
"format": []string{format},
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
},
})
if err != nil {
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
@@ -160,11 +157,8 @@ var AppsDBDataExport = common.Shortcut{
// queryExportTotal 调 GetAppTableRecordListpage_size=1取 total符合条件的记录总数
// 该接口与 +db-data-export 同为 spark:app:read scope避免导出命令被迫升级到写权限。
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
params := map[string]interface{}{"page_size": 1}
if env != "" {
params["env"] = env
}
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
map[string]interface{}{"env": env, "page_size": 1}, nil)
if err != nil {
return 0, err
}

View File

@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
{Name: "table", Desc: "target table (default: file name without extension)"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
return common.NewDryRunAPI().
POST(appDataImportPath(appID)).
Desc("Import data file into Miaoda app table (multipart upload)").
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -100,14 +100,10 @@ var AppsDBDataImport = common.Shortcut{
fd.AddField("file_name", fileName)
fd.AddFile("file", bytes.NewReader(content))
importQuery := larkcore.QueryParams{"table": []string{table}}
if env := dbEnv(rctx); env != "" {
importQuery["env"] = []string{env}
}
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: appDataImportPath(appID),
QueryParams: importQuery,
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {

View File

@@ -121,31 +121,6 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
}
}
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
p := env.API[0].Params
if _, ok := p["env"]; ok {
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
}
if p["table"] != "orders" {
t.Fatalf("table should still default to file basename, got params=%v", p)
}
}
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
func TestAppsDBDataImport_Success(t *testing.T) {
chdirTemp(t)

View File

@@ -97,16 +97,6 @@ var AppsDBEnvMigrate = common.Shortcut{
if err != nil {
return err
}
// 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply服务端在未经
// dry_run 预热时直接 apply虽发布成功却把 changes_applied 回填成 0展示「Migrated (0 changes)」)。
// 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断,
// 交由下面真实 apply 统一报同样的业务错。
pending := 0
var previewFrom, previewTo string
if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil {
pending = len(projectMigrationChanges(preview["changes"]))
previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to")
}
stop := rctx.StartSpinner("Applying migration (dev → online)")
defer stop()
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
@@ -114,12 +104,6 @@ var AppsDBEnvMigrate = common.Shortcut{
return withAppsHint(err, dbEnvMigrateHint)
}
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
if from == "" {
from = previewFrom
}
if to == "" {
to = previewTo
}
taskID := common.GetString(submit, "task_id")
applied := intFromAny(submit["changes_applied"])
if applied == 0 {
@@ -147,10 +131,6 @@ var AppsDBEnvMigrate = common.Shortcut{
applied = n
}
}
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
if applied == 0 && pending > 0 {
applied = pending
}
stop() // clear spinner before printing the result
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
rctx.OutFormat(out, nil, func(w io.Writer) {

View File

@@ -105,10 +105,8 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
// 异步submit 返 task_idstatus 立刻 applied → CLI 对外统一 migrated。
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
// ReusableExecute 现在会先打一次 dry_run 预览拿待发布数、再打 apply对齐 miaoda-cli 的
// diff-then-apply兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
Method: "POST", URL: dbEnvMigrateURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
})
reg.Register(&httpmock.Stub{
@@ -128,10 +126,8 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
// ReusableExecute 现在会先打一次 dry_run 预览拿待发布数、再打 apply对齐 miaoda-cli 的
// diff-then-apply兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
Method: "POST", URL: dbEnvMigrateURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
})
reg.Register(&httpmock.Stub{
@@ -323,31 +319,6 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
}
// 配额未对接storage_quota_bytes=0→ json 删 quota/usage_percent仅留已用量与 tables/views。
// TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 quota-get 的 dry-run
// query 不带 env 键(交服务端按应用形态自动选分支)。
func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBQuotaGet,
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbQuotaURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if _, ok := a.Params["env"]; ok {
t.Fatalf("no --environment → env key must be omitted, got params=%v", a.Params)
}
}
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{

View File

@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
Input: []string{common.Stdin}},
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -291,9 +291,10 @@ func parseErrorSentinel(data string) (int, string) {
//
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
return dbEnvParams(rctx, map[string]interface{}{
return map[string]interface{}{
"env": dbEnv(rctx),
"transactional": false,
})
}
}
// resolveExecuteSQL 返回要执行的 SQL在用时DryRun/Execute现读使 --file 的内容

View File

@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
return common.NewDryRunAPI().
GET(appDbQuotaPath(appID)).
Desc("Get Miaoda app database storage usage").
Params(dbEnvParams(rctx, map[string]interface{}{}))
Params(map[string]interface{}{"env": dbEnv(rctx)})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}

View File

@@ -32,23 +32,19 @@ var AppsDBRecoveryDiff = common.Shortcut{
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
return normalizeTimeFlags(rctx, "target")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -85,23 +81,19 @@ var AppsDBRecoveryApply = common.Shortcut{
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
return normalizeTimeFlags(rctx, "target")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -112,7 +104,7 @@ var AppsDBRecoveryApply = common.Shortcut{
target := rctx.Str("target")
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
if err != nil {
return withAppsHint(err, dbRecoveryHint)
}
@@ -127,7 +119,7 @@ var AppsDBRecoveryApply = common.Shortcut{
}
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "status")) {
@@ -165,7 +157,7 @@ var AppsDBRecoveryApply = common.Shortcut{
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
if err != nil {
return nil, withAppsHint(err, dbRecoveryHint)
}
@@ -175,7 +167,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
}
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "preview_status")) {
@@ -203,13 +195,13 @@ type recoveryChange struct {
// recoveryDiffOutput 组装 diff 输出target / tables_affected / changes[] / estimated_seconds。
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
arr, _ := preview["changes"].([]interface{})
raw := make([]recoveryChange, 0, len(arr))
changes := make([]recoveryChange, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
raw = append(raw, recoveryChange{
changes = append(changes, recoveryChange{
Table: common.GetString(m, "table"),
Inserted: m["inserted"],
Deleted: m["deleted"],
@@ -217,33 +209,16 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
DroppedAt: common.GetString(m, "dropped_at"),
})
}
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
// 两行 + tables_affected 翻倍。
hasSchema := map[string]bool{}
for _, c := range raw {
if c.Action != "" {
hasSchema[c.Table] = true
}
}
changes := make([]recoveryChange, 0, len(raw))
for _, c := range raw {
if c.Action == "" && hasSchema[c.Table] {
continue
}
changes = append(changes, c)
}
// tables_affected 按去重后的不同表数计(而非变更条数)。
seen := map[string]bool{}
for _, c := range changes {
seen[c.Table] = true
tablesAffected := intFromAny(preview["tables_affected"])
if tablesAffected == 0 {
tablesAffected = len(changes)
}
est := intFromAny(preview["estimated_seconds"])
if est == 0 {
est = 30 // PRD 兜底
}
return map[string]interface{}{
"target": target, "tables_affected": len(seen),
"target": target, "tables_affected": tablesAffected,
"changes": changes, "estimated_seconds": est,
}
}

View File

@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "table", Desc: "table name", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -80,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl要求返 CREATE 语句文本;
// 其他 format含默认 json不传该参数让 server 返默认结构化字段。
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{})
params := map[string]interface{}{"env": dbEnv(rctx)}
if rctx.Format == "pretty" {
params["format"] = "ddl"
}

View File

@@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
@@ -43,7 +42,7 @@ var AppsDBTableList = common.Shortcut{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -111,9 +110,10 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
}
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{
params := map[string]interface{}{
"env": dbEnv(rctx),
"page_size": rctx.Int("page-size"),
})
}
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
params["page_token"] = token
}
@@ -286,17 +286,6 @@ func numericAsFloat(raw interface{}) (float64, bool) {
return 0, false
}
return f, true
case string:
// 服务端有些数值字段(如 recovery diff 的 inserted/deleted 行数)以字符串下发。
s := strings.TrimSpace(v)
if s == "" {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return f, true
case nil:
return 0, false
}

View File

@@ -236,11 +236,7 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
{"json.Number valid", json.Number("13.5"), 13.5, true},
{"json.Number invalid", json.Number("abc"), 0, false},
{"nil", nil, 0, false},
{"non-numeric string", "x", 0, false},
{"numeric string", "13.5", 13.5, true},
{"numeric string int", "2", 2, true},
{"numeric string padded", " 13.5 ", 13.5, true},
{"empty string", "", 0, false},
{"unsupported string", "x", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View File

@@ -34,16 +34,6 @@ func dbEnv(rctx *common.RuntimeContext) string {
return rctx.Str("environment")
}
// dbEnvParams 把 env 并入 params仅当显式指定了环境非空才带 env 键;未指定(空)时
// 省略该键由服务端按应用多环境状态自动选分支多环境→dev单环境→online。与家族对
// 空可选参数的 omit-empty 约定一致——不发空串wire 上真正不带 env。原样返回同一个 map 便于链式。
func dbEnvParams(rctx *common.RuntimeContext, params map[string]interface{}) map[string]interface{} {
if env := dbEnv(rctx); env != "" {
params["env"] = env
}
return params
}
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env显式传了就报清晰的 validation 错,指向 --environment。
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
if rctx.Changed("env") {

View File

@@ -27,17 +27,12 @@ const (
html5BlockDataAttr = "data"
html5BlockReferenceRoot = "doc-fetch-resources"
html5BlockReferenceMaxRaw = 1024
whiteboardTag = "whiteboard"
whiteboardTypeAttr = "type"
whiteboardPathAttr = "path"
)
var (
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
)
type html5BlockReferenceEntry struct {
@@ -63,11 +58,6 @@ type html5BlockStartTag struct {
SelfClosing bool
}
type whiteboardStartTag struct {
Attrs []html5BlockAttr
SelfClosing bool
}
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildCreateBody(runtime)
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
@@ -125,11 +115,7 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
return docsV2WriteInput{}, err
}
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
if err != nil {
return docsV2WriteInput{}, err
}
content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap)
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
if err != nil {
return docsV2WriteInput{}, err
}
@@ -246,248 +232,6 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string
return out, compactReferenceMap(refMap), nil
}
func prepareWhiteboardWriteContent(runtime *common.RuntimeContext, format string, content string) (string, error) {
if !strings.Contains(content, "<whiteboard") {
return content, nil
}
rewrite := func(segment string) (string, error) {
return rewriteWhiteboardFileRefs(runtime, segment)
}
if strings.TrimSpace(format) != "markdown" {
return rewrite(content)
}
var rewriteErrs []error
out := applyOutsideCodeFences(content, func(segment string) string {
outSegment, rewriteErr := rewrite(segment)
if rewriteErr != nil {
rewriteErrs = append(rewriteErrs, rewriteErr)
return segment
}
return outSegment
})
if len(rewriteErrs) > 0 {
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
}
return out, nil
}
func rewriteWhiteboardFileRefs(runtime *common.RuntimeContext, content string) (string, error) {
var rewriteErrs []error
out := whiteboardElementPattern.ReplaceAllStringFunc(content, func(raw string) string {
rewritten, err := rewriteWhiteboardFileRef(runtime, raw)
if err != nil {
rewriteErrs = append(rewriteErrs, err)
return raw
}
return rewritten
})
if len(rewriteErrs) > 0 {
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
}
return out, nil
}
func rewriteWhiteboardFileRef(runtime *common.RuntimeContext, raw string) (string, error) {
startRaw, body, _, ok := splitWhiteboardElement(raw)
if !ok {
return raw, nil
}
tag, err := parseWhiteboardStartTag(startRaw)
if err != nil {
return "", common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard")
}
pathValue, hasPath := tag.attr(whiteboardPathAttr)
bodyPath, hasBodyPath := whiteboardBodyPathRef(body)
if !hasPath && !hasBodyPath {
return raw, nil
}
if hasPath && strings.TrimSpace(body) != "" {
return "", common.ValidationErrorf("whiteboard cannot contain both path and inline content").WithParam("whiteboard")
}
if hasPath && hasBodyPath {
return "", common.ValidationErrorf("whiteboard cannot contain both path and @file body").WithParam("whiteboard")
}
typRaw, ok := tag.attr(whiteboardTypeAttr)
if !ok || strings.TrimSpace(typRaw) == "" {
return "", common.ValidationErrorf("whiteboard file input requires type=\"svg\", type=\"mermaid\", or type=\"plantuml\"").WithParam("type")
}
typ, ok := canonicalWhiteboardFileType(typRaw)
if !ok {
return "", common.ValidationErrorf("whiteboard file input only supports type=\"svg\", type=\"mermaid\", or type=\"plantuml\", got %q", typRaw).WithParam("type")
}
if hasBodyPath {
pathValue = bodyPath
}
data, err := readWhiteboardPath(runtime, pathValue, typ)
if err != nil {
return "", err
}
tag.setAttr(whiteboardTypeAttr, typ)
tag.removeAttrs(whiteboardPathAttr)
return tag.render(false) + whiteboardContentForType(typ, data) + "</" + whiteboardTag + ">", nil
}
func splitWhiteboardElement(raw string) (startTag string, body string, selfClosing bool, ok bool) {
trimmed := strings.TrimSpace(raw)
selfClosing = strings.HasSuffix(trimmed, "/>")
if selfClosing {
return raw, "", true, true
}
startEnd := strings.Index(raw, ">")
if startEnd < 0 {
return "", "", false, false
}
endStart := strings.LastIndex(strings.ToLower(raw), "</whiteboard>")
if endStart < 0 || endStart < startEnd {
return "", "", false, false
}
return raw[:startEnd+1], raw[startEnd+1 : endStart], false, true
}
func whiteboardBodyPathRef(body string) (string, bool) {
trimmed := strings.TrimSpace(body)
if !strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "@@") {
return "", false
}
if strings.ContainsAny(trimmed, "\r\n") {
return "", false
}
return trimmed, true
}
func canonicalWhiteboardFileType(raw string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "svg":
return "svg", true
case "mermaid":
return "mermaid", true
case "plantuml":
return "plantuml", true
default:
return "", false
}
}
func readWhiteboardPath(runtime *common.RuntimeContext, pathValue string, typ string) (string, error) {
pathRaw := strings.TrimSpace(pathValue)
if !strings.HasPrefix(pathRaw, "@") {
return "", common.ValidationErrorf("whiteboard %s path %q must start with @, for example @diagram.%s", typ, pathValue, exampleWhiteboardExt(typ)).WithParam("path")
}
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
if relPath == "" {
return "", common.ValidationErrorf("whiteboard %s path cannot be empty after @", typ).WithParam("path")
}
clean := filepath.Clean(relPath)
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", common.ValidationErrorf("whiteboard %s path %q must be a relative path within the current working directory", typ, pathValue).WithParam("path")
}
if !whiteboardExtAllowed(typ, strings.ToLower(filepath.Ext(clean))) {
return "", common.ValidationErrorf("whiteboard %s path %q must point to a %s file", typ, pathValue, whiteboardExtList(typ)).WithParam("path")
}
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
if err != nil {
return "", common.ValidationErrorf("whiteboard %s path %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", typ, clean, err).
WithParam("path").
WithParams(errs.InvalidParam{Name: clean, Reason: fmt.Sprintf("whiteboard %s path cannot be read", typ)}).
WithCause(err)
}
return string(data), nil
}
func whiteboardExtAllowed(typ string, ext string) bool {
for _, allowed := range whiteboardAllowedExts(typ) {
if ext == allowed {
return true
}
}
return false
}
func whiteboardAllowedExts(typ string) []string {
switch typ {
case "svg":
return []string{".svg"}
case "mermaid":
return []string{".mermaid", ".mmd"}
case "plantuml":
return []string{".plantuml", ".puml", ".pu", ".uml"}
default:
return nil
}
}
func whiteboardExtList(typ string) string {
return strings.Join(whiteboardAllowedExts(typ), ", ")
}
func exampleWhiteboardExt(typ string) string {
exts := whiteboardAllowedExts(typ)
if len(exts) == 0 {
return "txt"
}
return strings.TrimPrefix(exts[0], ".")
}
func whiteboardContentForType(typ string, data string) string {
if typ == "svg" {
return data
}
return escapeXMLText(data)
}
func aggregateWhiteboardRewriteErrors(rewriteErrs []error) error {
flatErrs := flattenWhiteboardRewriteErrors(rewriteErrs)
messages := make([]string, 0, len(flatErrs))
params := make([]errs.InvalidParam, 0, len(flatErrs))
for _, err := range flatErrs {
messages = append(messages, err.Error())
params = append(params, whiteboardInvalidParamsFromError(err)...)
}
validationErr := common.ValidationErrorf("whiteboard file input failed: %s", strings.Join(messages, "; ")).
WithParam("whiteboard").
WithCause(errors.Join(flatErrs...))
if len(params) > 0 {
validationErr.WithParams(params...)
}
return validationErr
}
func flattenWhiteboardRewriteErrors(rewriteErrs []error) []error {
flatErrs := make([]error, 0, len(rewriteErrs))
for _, err := range rewriteErrs {
var validationErr *errs.ValidationError
if errors.As(err, &validationErr) && validationErr.Param == "whiteboard" && validationErr.Cause != nil {
if joined, ok := validationErr.Cause.(interface{ Unwrap() []error }); ok {
flatErrs = append(flatErrs, flattenWhiteboardRewriteErrors(joined.Unwrap())...)
continue
}
}
flatErrs = append(flatErrs, err)
}
return flatErrs
}
func whiteboardInvalidParamsFromError(err error) []errs.InvalidParam {
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
return nil
}
if len(validationErr.Params) > 0 {
return validationErr.Params
}
if validationErr.Param != "" {
return []errs.InvalidParam{{Name: validationErr.Param, Reason: validationErr.Message}}
}
return nil
}
func validateHTML5BlockWriteElementBodies(format string, content string) error {
validateSegment := func(segment string) error {
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
@@ -877,34 +621,6 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) {
trimmed := strings.TrimSpace(raw)
selfClosing := strings.HasSuffix(trimmed, "/>")
decoder := xml.NewDecoder(strings.NewReader(raw))
for {
tok, err := decoder.Token()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return whiteboardStartTag{}, err
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != whiteboardTag {
return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
attrs := make([]html5BlockAttr, 0, len(start.Attr))
for _, attr := range start.Attr {
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
}
return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
}
return whiteboardStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func (t html5BlockStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
@@ -914,15 +630,6 @@ func (t html5BlockStartTag) attr(name string) (string, bool) {
return "", false
}
func (t whiteboardStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
return attr.Value, true
}
}
return "", false
}
func (t html5BlockStartTag) hasAttr(name string) bool {
_, ok := t.attr(name)
return ok
@@ -943,31 +650,6 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) {
t.Attrs = attrs
}
func (t *whiteboardStartTag) removeAttrs(names ...string) {
remove := make(map[string]struct{}, len(names))
for _, name := range names {
remove[name] = struct{}{}
}
attrs := t.Attrs[:0]
for _, attr := range t.Attrs {
if _, ok := remove[attr.Name]; ok {
continue
}
attrs = append(attrs, attr)
}
t.Attrs = attrs
}
func (t *whiteboardStartTag) setAttr(name string, value string) {
for i, attr := range t.Attrs {
if attr.Name == name {
t.Attrs[i].Value = value
return
}
}
t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value})
}
func (t html5BlockStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
@@ -992,25 +674,6 @@ func (t html5BlockStartTag) render(selfClosing bool) string {
return b.String()
}
func (t whiteboardStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
b.WriteString(whiteboardTag)
for _, attr := range t.Attrs {
b.WriteByte(' ')
b.WriteString(attr.Name)
b.WriteString(`="`)
b.WriteString(escapeXMLAttr(attr.Value))
b.WriteByte('"')
}
if selfClosing {
b.WriteString("/>")
} else {
b.WriteByte('>')
}
return b.String()
}
func escapeXMLAttr(value string) string {
var b strings.Builder
for _, r := range value {
@@ -1031,18 +694,3 @@ func escapeXMLAttr(value string) string {
}
return b.String()
}
func escapeXMLText(value string) string {
var b strings.Builder
for _, r := range value {
switch r {
case '&':
b.WriteString("&amp;")
case '<':
b.WriteString("&lt;")
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -6,13 +6,11 @@ package doc
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
@@ -118,61 +116,6 @@ func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
}
}
func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
files := map[string]string{
"diagram.svg": `<svg viewBox="0 0 10 10"><text>A</text></svg>`,
"flow.mmd": "flowchart TD\nA --> B",
"sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml",
}
for name, content := range files {
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile(%s) error: %v", name, err)
}
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@diagram.svg"></whiteboard>`,
`<whiteboard type="mermaid">@flow.mmd</whiteboard>`,
`<whiteboard type="plantUML" path="@sequence.puml"/>`,
}, "\n"),
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
got := body["content"].(string)
for _, want := range []string{
`<whiteboard type="svg"><svg viewBox="0 0 10 10"><text>A</text></svg></whiteboard>`,
"<whiteboard type=\"mermaid\">flowchart TD\nA --> B</whiteboard>",
"<whiteboard type=\"plantuml\">@startuml\nAlice -> Bob: hi\n@enduml</whiteboard>",
} {
if !strings.Contains(got, want) {
t.Fatalf("content missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `path="@`) {
t.Fatalf("content still contains whiteboard path attr: %s", got)
}
if _, ok := body["reference_map"]; ok {
t.Fatalf("whiteboard file input must not create reference_map: %#v", body)
}
}
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
for _, flag := range flags {
if flag.Name == name {
@@ -464,119 +407,6 @@ func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
}
}
func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@missing.svg"></whiteboard>`,
`<whiteboard type="mermaid">@missing.mmd</whiteboard>`,
`<whiteboard type="plantuml" path="@missing.puml"></whiteboard>`,
}, "\n"),
"--as", "user",
})
if err == nil {
t.Fatal("expected aggregated whiteboard path error")
}
assertWhiteboardFileInputValidation(t, err, []string{
"missing.svg",
"missing.mmd",
"missing.puml",
}, []string{
`whiteboard svg path "missing.svg" cannot be read`,
`whiteboard mermaid path "missing.mmd" cannot be read`,
`whiteboard plantuml path "missing.puml" cannot be read`,
})
}
func TestDocsCreateV2WhiteboardFileInputMarkdownReportsMissingPathsAcrossFences(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--doc-format", "markdown",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@before.svg"></whiteboard>`,
"```",
`<whiteboard type="svg" path="@inside.svg"></whiteboard>`,
"```",
`<whiteboard type="plantuml" path="@after.puml"></whiteboard>`,
}, "\n"),
"--as", "user",
})
if err == nil {
t.Fatal("expected aggregated whiteboard path error")
}
assertWhiteboardFileInputValidation(t, err, []string{
"before.svg",
"after.puml",
}, []string{
`whiteboard svg path "before.svg" cannot be read`,
`whiteboard plantuml path "after.puml" cannot be read`,
})
if strings.Contains(err.Error(), "inside.svg") {
t.Fatalf("error should ignore fenced whiteboard path, got: %v", err)
}
}
func assertWhiteboardFileInputValidation(t *testing.T, err error, wantParams []string, wantMessages []string) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category/subtype = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if validationErr.Param != "whiteboard" {
t.Fatalf("param = %q, want whiteboard", validationErr.Param)
}
if validationErr.Cause == nil {
t.Fatal("expected aggregated error to preserve cause")
}
var childValidationErr *errs.ValidationError
if !errors.As(validationErr.Cause, &childValidationErr) || childValidationErr.Cause == nil {
t.Fatalf("expected child validation cause to preserve file read cause, got %#v", validationErr.Cause)
}
gotParams := make(map[string]string, len(validationErr.Params))
for _, param := range validationErr.Params {
gotParams[param.Name] = param.Reason
}
if len(gotParams) != len(wantParams) {
t.Fatalf("params = %#v, want names %v", validationErr.Params, wantParams)
}
for _, param := range wantParams {
reason, ok := gotParams[param]
if !ok {
t.Fatalf("params = %#v, want name %q", validationErr.Params, param)
}
if reason == "" {
t.Fatalf("param %q missing reason: %#v", param, validationErr.Params)
}
}
for _, want := range wantMessages {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q:\n%v", want, err)
}
if !strings.Contains(validationErr.Cause.Error(), want) {
t.Fatalf("cause missing %q:\n%v", want, validationErr.Cause)
}
}
}
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)

View File

@@ -1,325 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"io"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveMemberListSpec struct {
Token string
Type string
Fields string
PermType string
}
var driveMemberListTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "minutes", "slides", "folder",
}
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
var driveMemberListPermTypes = []string{"container", "single_page"}
var driveMemberListURLPathToType = []struct {
Prefix string
Type string
}{
{"/drive/folder/", "folder"},
{"/docx/", "docx"},
{"/doc/", "doc"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/wiki/", "wiki"},
{"/file/", "file"},
{"/mindnotes/", "mindnote"},
{"/slides/", "slides"},
{"/minutes/", "minutes"},
}
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveMemberListSpec{}, err
}
fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields"))
if err != nil {
return driveMemberListSpec{}, err
}
permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type"))
if err != nil {
return driveMemberListSpec{}, err
}
return driveMemberListSpec{
Token: token,
Type: resourceType,
Fields: fields,
PermType: permType,
}, nil
}
func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type")
if err != nil {
return "", "", err
}
if strings.Contains(raw, "://") {
parsed, parseErr := url.Parse(raw)
if parseErr != nil || parsed.Hostname() == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
}
ref, ok := parseDriveMemberListResourceURLPath(parsed.Path)
if !ok {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
raw,
).WithParam("--token")
}
if explicitType != "" && explicitType != ref.Type {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
explicitType,
ref.Type,
).WithParam("--type")
}
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return ref.Token, ref.Type, nil
}
if explicitType == "" {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(driveMemberListTypes, ", "),
).WithParam("--type")
}
if err := validate.ResourceName(raw, "--token"); err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return raw, explicitType, nil
}
func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) {
for _, mapping := range driveMemberListURLPathToType {
if !strings.HasPrefix(path, mapping.Prefix) {
continue
}
token := path[len(mapping.Prefix):]
token = strings.TrimRight(token, "/")
if idx := strings.IndexByte(token, '/'); idx >= 0 {
token = token[:idx]
}
token = strings.TrimSpace(token)
if token == "" {
return common.ResourceRef{}, false
}
return common.ResourceRef{Type: mapping.Type, Token: token}, true
}
return common.ResourceRef{}, false
}
func normalizeDriveMemberListFields(raw string, changed bool) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
if changed {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
}
return "", nil
}
parts := strings.Split(raw, ",")
fields := make([]string, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
field := strings.ToLower(strings.TrimSpace(part))
if field == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
}
if field == "*" {
if len(parts) != 1 {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields")
}
return "*", nil
}
if !driveMemberListFieldAllowed(field) {
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid value %q for --fields, allowed: %s, *",
strings.TrimSpace(part),
strings.Join(driveMemberListFields, ", "),
).WithParam("--fields")
}
if !seen[field] {
fields = append(fields, field)
seen[field] = true
}
}
return strings.Join(fields, ","), nil
}
func driveMemberListFieldAllowed(field string) bool {
for _, allowed := range driveMemberListFields {
if field == allowed {
return true
}
}
return false
}
func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) {
permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type")
if err != nil {
return "", err
}
if resourceType != "wiki" && changed {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
}
return permType, nil
}
func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", nil
}
for _, candidate := range allowed {
if strings.EqualFold(value, candidate) {
return candidate, nil
}
}
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid value %q for %s, allowed: %s",
value,
flagName,
strings.Join(allowed, ", "),
).WithParam(flagName)
}
func (s driveMemberListSpec) apiPath() string {
return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token))
}
func (s driveMemberListSpec) params() map[string]interface{} {
params := map[string]interface{}{"type": s.Type}
if s.Fields != "" {
params["fields"] = s.Fields
}
if s.PermType != "" {
params["perm_type"] = s.PermType
}
return params
}
// DriveMemberList lists collaborator/member permissions on a Drive resource.
var DriveMemberList = common.Shortcut{
Service: "drive",
Command: "+member-list",
Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node",
Risk: "read",
Scopes: []string{"docs:permission.member:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"},
{Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"},
{Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"},
},
Tips: []string{
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
"Use --type folder for Drive folders.",
"--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.",
"--perm-type only applies to wiki nodes.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveMemberListSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("List Drive collaborator/member permissions").
GET(spec.apiPath()).
Params(spec.params())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil)
if err != nil {
return err
}
if items, ok := data["items"].([]interface{}); ok {
fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items))
}
runtime.OutFormat(data, nil, func(w io.Writer) {
renderDriveMemberListPretty(w, data)
})
return nil
},
}
func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) {
items, _ := data["items"].([]interface{})
if len(items) == 0 {
fmt.Fprintln(w, "No Drive members found.")
return
}
for i, raw := range items {
member, _ := raw.(map[string]interface{})
fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"]))
fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"]))
fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"]))
if permType := driveMemberListValue(member["perm_type"]); permType != "-" {
fmt.Fprintf(w, " perm_type: %s\n", permType)
}
if memberType := driveMemberListValue(member["type"]); memberType != "-" {
fmt.Fprintf(w, " type: %s\n", memberType)
}
if name := driveMemberListValue(member["name"]); name != "-" {
fmt.Fprintf(w, " name: %s\n", name)
}
if avatar := driveMemberListValue(member["avatar"]); avatar != "-" {
fmt.Fprintf(w, " avatar: %s\n", avatar)
}
if label, ok := member["external_label"]; ok {
fmt.Fprintf(w, " external_label: %v\n", label)
}
fmt.Fprintln(w)
}
}
func driveMemberListValue(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "-"
}

View File

@@ -1,424 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "drive +member-list"}
cmd.Flags().String("token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("fields", "", "")
cmd.Flags().String("perm-type", "", "")
for name, value := range map[string]string{
"token": token,
"type": docType,
"fields": fields,
"perm-type": permType,
} {
if value == "" {
continue
}
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return common.TestNewRuntimeContext(cmd, driveTestConfig())
}
func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantTok string
wantType string
}{
{
name: "folder URL",
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "docx URL",
token: "https://example.feishu.cn/docx/doxTok",
wantTok: "doxTok",
wantType: "docx",
},
{
name: "bare folder token",
token: " fldTok ",
docType: " folder ",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "mindnotes URL",
token: "https://example.feishu.cn/mindnotes/mndTok",
wantTok: "mndTok",
wantType: "mindnote",
},
{
name: "minutes URL",
token: "https://example.feishu.cn/minutes/obTok",
wantTok: "obTok",
wantType: "minutes",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "")
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if spec.Token != tt.wantTok || spec.Type != tt.wantType {
t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType)
}
})
}
}
func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
fields string
permType string
wantParam string
wantMessage string
}{
{
name: "missing token",
wantParam: "--token",
wantMessage: "--token is required",
},
{
name: "bare token without type",
token: "doxTok",
wantParam: "--type",
wantMessage: "--type is required",
},
{
name: "unsupported URL",
token: "https://example.feishu.cn/calendar/calTok",
wantParam: "--token",
wantMessage: "unsupported --token URL",
},
{
name: "URL type conflict",
token: "https://example.feishu.cn/docx/doxTok",
docType: "folder",
wantParam: "--type",
wantMessage: "conflicts with URL path type",
},
{
name: "invalid bare token",
token: "../bad",
docType: "folder",
wantParam: "--token",
wantMessage: "--token",
},
{
name: "invalid type",
token: "doxTok",
docType: "comment",
wantParam: "--type",
wantMessage: "invalid value",
},
{
name: "invalid fields",
token: "doxTok",
docType: "docx",
fields: "name,unknown",
wantParam: "--fields",
wantMessage: "invalid value",
},
{
name: "star mixed with fields",
token: "doxTok",
docType: "docx",
fields: "*,name",
wantParam: "--fields",
wantMessage: "cannot be combined",
},
{
name: "perm type rejected for non-wiki",
token: "doxTok",
docType: "docx",
permType: "single_page",
wantParam: "--perm-type",
wantMessage: "only applies when resource type is wiki",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
_, err := readDriveMemberListSpec(runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if validationErr.Param != tt.wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
}
})
}
}
func TestDriveMemberListSpecParams(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
fields string
permType string
want map[string]interface{}
}{
{
name: "default omits optional params",
token: "doxTok",
docType: "docx",
want: map[string]interface{}{"type": "docx"},
},
{
name: "fields canonicalized and deduplicated",
token: "doxTok",
docType: "docx",
fields: "Name,avatar,name",
want: map[string]interface{}{"type": "docx", "fields": "name,avatar"},
},
{
name: "wiki accepts perm type",
token: "wikTok",
docType: "WIKI",
fields: "*",
permType: "SINGLE_PAGE",
want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if got := spec.params(); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("params = %#v, want %#v", got, tt.want)
}
})
}
}
func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveMemberList, []string{
"+member-list",
"--token", "https://example.feishu.cn/drive/folder/fldTok",
"--fields", "*",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if len(got.API) != 1 {
t.Fatalf("api count = %d, want 1", len(got.API))
}
api := got.API[0]
if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" {
t.Fatalf("api = %#v", api)
}
if api.Params["type"] != "folder" || api.Params["fields"] != "*" {
t.Fatalf("params = %#v", api.Params)
}
if _, ok := api.Params["perm_type"]; ok {
t.Fatalf("perm_type should be omitted for folder: %#v", api.Params)
}
}
func TestDriveMemberListExecutePreservesRawData(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
var capturedQuery string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/permissions/doxTok/members",
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"member_id": "ou_x",
"member_type": "openid",
"perm": "view",
"type": "user",
"name": "zhangsan",
"server_future": "preserved",
"external_label": true,
},
},
"server_top_level": "preserved",
},
},
})
err := mountAndRunDrive(t, DriveMemberList, []string{
"+member-list",
"--token", "doxTok",
"--type", "docx",
"--fields", "name,type,external_label",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(capturedQuery, "type=docx") ||
!strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") {
t.Fatalf("captured query = %q", capturedQuery)
}
data := decodeDriveEnvelope(t, stdout)
if data["server_top_level"] != "preserved" {
t.Fatalf("server_top_level = %#v", data["server_top_level"])
}
for _, key := range []string{"token", "type", "count"} {
if _, ok := data[key]; ok {
t.Fatalf("data[%s] = %#v, want omitted", key, data[key])
}
}
items, _ := data["items"].([]interface{})
if len(items) != 1 {
t.Fatalf("items = %#v, want one item", data["items"])
}
item, _ := items[0].(map[string]interface{})
if item["server_future"] != "preserved" || item["external_label"] != true {
t.Fatalf("item future fields not preserved: %#v", item)
}
if !strings.Contains(stderr.String(), "Found 1 Drive member") {
t.Fatalf("stderr = %q, want count log", stderr.String())
}
}
func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) {
t.Parallel()
if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) {
t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes)
}
if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) {
t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes)
}
}
func TestDriveMemberListPrettyOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/permissions/wikTok/members",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"member_id": "ou_x",
"member_type": "openid",
"perm": "view",
"perm_type": "single_page",
"type": "user",
"name": "zhangsan",
},
},
},
},
})
err := mountAndRunDrive(t, DriveMemberList, []string{
"+member-list",
"--token", "wikTok",
"--type", "wiki",
"--perm-type", "single_page",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q:\n%s", want, out)
}
}
}

View File

@@ -31,7 +31,6 @@ func Shortcuts() []common.Shortcut {
DriveTaskResult,
DriveApplyPermission,
DriveMemberAdd,
DriveMemberList,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,

View File

@@ -37,7 +37,6 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+task_result",
"+apply-permission",
"+member-add",
"+member-list",
"+secure-label-list",
"+secure-label-update",
"+search",

View File

@@ -199,19 +199,6 @@ func TestWikiNodeListNormalizesWikiURLParentNodeToken(t *testing.T) {
}
}
func TestWikiNodeListAcceptsOpaqueParentNodeToken(t *testing.T) {
t.Parallel()
const opaqueNodeToken = "Q6ZM_EXAMPLE_TOKEN"
token, err := normalizeWikiNodeListParentToken(opaqueNodeToken)
if err != nil {
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
}
if token != opaqueNodeToken {
t.Fatalf("token = %q, want %q", token, opaqueNodeToken)
}
}
func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
t.Parallel()
@@ -237,6 +224,11 @@ func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
input: "wik_placeholder/child",
wantMsg: "raw wiki node token",
},
{
name: "document token",
input: "docx_placeholder_parent",
wantMsg: "must be a wiki node token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -359,11 +351,10 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
const parentNodeToken = "Q6ZM_EXAMPLE_TOKEN"
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=" + parentNodeToken,
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
@@ -374,7 +365,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
"node_token": "wik_child",
"obj_token": "docx_child",
"obj_type": "docx",
"parent_node_token": parentNodeToken,
"parent_node_token": "wik_parent",
"node_type": "origin",
"title": "Child Doc",
"has_child": false,
@@ -387,7 +378,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
reg.Register(stub)
err := mountAndRunWiki(t, WikiNodeList, []string{
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", parentNodeToken, "--as", "bot",
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
@@ -409,8 +400,8 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
if len(envelope.Data.Nodes) != 1 {
t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes))
}
if envelope.Data.Nodes[0]["parent_node_token"] != parentNodeToken {
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], parentNodeToken)
if envelope.Data.Nodes[0]["parent_node_token"] != "wik_parent" {
t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], "wik_parent")
}
}

View File

@@ -69,8 +69,8 @@ var WikiNodeGet = common.Shortcut{
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
},
Tips: []string{
"--node-token accepts a raw wiki node_token, obj_token, or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"For raw obj_tokens, pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
"--token is the deprecated original name and still works for backward compatibility; new scripts should use --node-token.",
},
@@ -235,10 +235,29 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
).WithParam("--node-token")
} else {
spec.Token = tokenInput
if spec.ObjType == "" {
if looksLikeWikiNodeToken(spec.Token) {
spec.SourceKind = "raw-node"
// node_tokens take no obj_type; reject a conflicting flag rather
// than silently passing it (the API would just ignore it, but the
// mismatch signals caller confusion).
if spec.ObjType != "" {
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type is only valid for obj_tokens; %q looks like a node_token",
spec.Token,
).WithParam("--obj-type")
}
} else {
spec.SourceKind = "raw-obj"
// A raw obj_token needs an explicit obj_type: get_node would
// otherwise default to "doc" and fail confusingly for docx /
// sheet / bitable / ... Fail fast with the same upfront contract
// as +node-delete instead of deferring to an opaque API error.
if spec.ObjType == "" {
return wikiNodeGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--obj-type is required for a raw obj_token %q (one of: %s); or pass a typed Lark URL (e.g. /docx/<token>) so it can be inferred",
spec.Token, strings.Join(wikiNodeGetObjTypeEnum, ", "),
).WithParam("--obj-type")
}
}
}
@@ -251,6 +270,18 @@ func parseWikiNodeGetSpec(rawToken, rawObjType, rawSpaceID string) (wikiNodeGetS
return spec, nil
}
// looksLikeWikiNodeToken returns true when the token has the `wik` prefix used
// for node_tokens. Lark wiki tokens are case-insensitive in practice; callers
// pass `wikcn`/`wikus`/`Wik...` interchangeably, so normalize for the check.
//
// This is a heuristic based on the current Lark token-naming convention, not a
// guaranteed invariant: if Lark ever introduces a non-node token type that
// also starts with `wik`, it would be misclassified. Worst case is a
// confusing API error (no data risk); revisit if the token scheme changes.
func looksLikeWikiNodeToken(token string) bool {
return strings.HasPrefix(strings.ToLower(token), "wik")
}
// tokenAndObjTypeFromWikiURL extracts the token and inferred obj_type from a
// Lark URL path. The wiki path returns an empty obj_type because node_tokens
// don't need one.

View File

@@ -31,22 +31,6 @@ func TestParseWikiNodeGetSpecRawNodeToken(t *testing.T) {
}
}
func TestParseWikiNodeGetSpecOpaqueRawNodeToken(t *testing.T) {
t.Parallel()
const opaqueNodeToken = "Sm78_EXAMPLE_TOKEN"
spec, err := parseWikiNodeGetSpec(opaqueNodeToken, "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != opaqueNodeToken || spec.ObjType != "" || spec.SourceKind != "raw-node" {
t.Fatalf("spec = %+v, want raw-node %s with no obj_type", spec, opaqueNodeToken)
}
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": opaqueNodeToken}) {
t.Fatalf("RequestParams() = %v, want {token: %s}", got, opaqueNodeToken)
}
}
func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
t.Parallel()
@@ -59,30 +43,23 @@ func TestParseWikiNodeGetSpecRawObjTokenWithExplicitObjType(t *testing.T) {
}
}
func TestParseWikiNodeGetSpecRawTokenWithoutObjTypeDefaultsToNodeToken(t *testing.T) {
func TestParseWikiNodeGetSpecRejectsRawObjTokenWithoutObjType(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "bascnXYZ" || spec.ObjType != "" || spec.SourceKind != "raw-node" {
t.Fatalf("spec = %+v, want raw-node bascnXYZ with no obj_type", spec)
// Mirrors +node-delete: a raw obj_token with no --obj-type must fail
// upfront instead of defaulting to "doc" and hitting an opaque API error.
_, err := parseWikiNodeGetSpec("bascnXYZ", "", "")
if err == nil || !strings.Contains(err.Error(), "--obj-type is required for a raw obj_token") {
t.Fatalf("expected raw obj_token obj-type-required error, got %v", err)
}
}
func TestParseWikiNodeGetSpecRawTokenWithObjTypeUsesObjTokenLookup(t *testing.T) {
func TestParseWikiNodeGetSpecRejectsObjTypeOnNodeToken(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
if err != nil {
t.Fatalf("parseWikiNodeGetSpec() error = %v", err)
}
if spec.Token != "wikcnABC" || spec.ObjType != "docx" || spec.SourceKind != "raw-obj" {
t.Fatalf("spec = %+v, want raw-obj wikcnABC with obj_type docx", spec)
}
if got := spec.RequestParams(); !reflect.DeepEqual(got, map[string]interface{}{"token": "wikcnABC", "obj_type": "docx"}) {
t.Fatalf("RequestParams() = %v, want {token: wikcnABC, obj_type: docx}", got)
_, err := parseWikiNodeGetSpec("wikcnABC", "docx", "")
if err == nil || !strings.Contains(err.Error(), "only valid for obj_tokens") {
t.Fatalf("expected node_token + obj_type rejection, got %v", err)
}
}

View File

@@ -207,6 +207,11 @@ func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
).WithParam("--parent-node-token")
}
if !looksLikeWikiNodeToken(parentNodeToken) {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--parent-node-token must be a wiki node token; do not pass a docx/sheet/base/file token",
).WithParam("--parent-node-token").WithHint("Run `lark-cli wiki +node-get --node-token <url-or-token>` to resolve a document URL or obj_token to the wiki `node_token` first.")
}
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
return "", err
}

View File

@@ -18,6 +18,7 @@ import (
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
@@ -585,11 +586,15 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
}
func TestBuildAllowedHosts(t *testing.T) {
feishu := struct{ Open, Accounts, MCP string }{
"https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn",
feishu := core.Endpoints{
Open: "https://open.feishu.cn",
Accounts: "https://accounts.feishu.cn",
MCP: "https://mcp.feishu.cn",
}
lark := struct{ Open, Accounts, MCP string }{
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
lark := core.Endpoints{
Open: "https://open.larksuite.com",
Accounts: "https://accounts.larksuite.com",
MCP: "https://mcp.larksuite.com",
}
hosts := buildAllowedHosts(feishu, lark)
// feishu hosts

View File

@@ -11,7 +11,7 @@
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`shell 解析路径CLI 仅接收内容)。
- `--file``.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。
- `--environment` 枚举:`dev` / `online`**不传则由服务端按应用是否开启多环境自动选择(多环境→`dev`,未开启多环境→`online`**;要固定环境就显式传 `--environment dev|online`。**未开启多环境的应用显式传 `--environment dev` 会报错(无 dev 分支)——这类应用不传 `--environment`(走 `online`)或显式 `--environment online`**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`
- `--environment` 枚举:`dev` / `online`**默认 `dev`**;操作线上库、或**未开启多环境的应用(其数据库在 `online`,没有 dev 分支)**时显式 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`
- risk 是 `high-risk-write`SQL 可含 DML/DDL任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`
- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`详见下「Agent 规则」)。

View File

@@ -28,7 +28,7 @@
## 约定(先读)
- **环境 `--environment dev|online`可省略**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分。省略 `--environment` 时 CLI 不带该参数、由服务端按应用形态自动选分支——多环境应用走 `dev`未开多环境的 `online`;要固定环境就显式传。唯一会报错的组合:对未开多环境的应用显式 `--environment dev`(无 `dev` 分支)。写操作建议先在 `dev` 验(仅多环境应用有 `dev`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment``+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义**没有** `--environment`
- **环境 `--environment dev|online`所有 db 命令统一默认 `dev`**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment``+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义`+db-recovery-*` 作用于当前库,二者**没有** `--environment`
- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒validation / exit 2。路径在别处先 `cd` 过去或改成相对路径。
- **高危操作必须带 `--yes`**`+db-env-create``+db-data-import``+db-env-migrate``+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
- **时间参数按口语自然传**`--since`/`--until`/`--target`),格式见末尾。
@@ -154,7 +154,7 @@ lark-cli apps +db-quota-get --app-id app_xxx --environment dev
## Agent 规则
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)建议先在 `dev` 验再动 `online`**注意省略 `--environment` 时写操作会落到服务端选中的分支——单环境应用即 `online`(生产)**:不确定应用是否多环境时,写操作显式传 `--environment`;显式 `dev` 在单环境应用上会安全报错(无 dev 分支),正好当「是否多环境」的探针用。
- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`
- 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty``+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。
- 四个高危命令(`+db-env-create``+db-data-import``+db-env-migrate``+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--environment dev` 验;不要静默追加 `--yes`,遇 confirmation_requiredexit 10按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。
- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。

View File

@@ -44,8 +44,6 @@ SubAgent 插入 SVG。
</whiteboard>
```
如果 Mermaid 已在本地文件中,可写成 `<whiteboard type="mermaid" path="@diagram.mmd"></whiteboard>`CLI 会在写入前读取文件并展开为内联内容。
### 步骤 2B: SubAgent 使用 SVG 插入图表
主 Agent 启动 SubAgent让它用 `docs +create` / `docs +update` 插入:
@@ -58,8 +56,6 @@ SubAgent 插入 SVG。
</whiteboard>
```
如果 SVG 已在本地文件中,可写成 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>`PlantUML 文件同理使用 `<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
- doc token、插入位置标题 / block_id / command

View File

@@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
- `<img>``<img href="https://..."/>` 上传网络图片
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`也可用本地文件简写 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>``<whiteboard type="mermaid" path="@flow.mmd"></whiteboard>``<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`CLI 会写入前展开为内联内容;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
- `<sheet>``<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id

View File

@@ -150,7 +150,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical tokenwiki URL 会自动解包到底层文档。 |
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create真实写入需要 `--yes`。 |
| [`+member-list`](references/lark-drive-member-list.md) | 查询 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。 |
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |

View File

@@ -1,63 +0,0 @@
# drive +member-list查询协作者/授权成员列表)
本 skill 对应 shortcut`lark-cli drive +member-list`。它读取 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。
## 命令
```bash
# URL 自动推断 type
lark-cli drive +member-list \
--token 'https://example.feishu.cn/drive/folder/<folder_token>' \
--as user --format json
# 查询附加字段
lark-cli drive +member-list \
--token '<token>' \
--type docx \
--fields 'name,type,external_label' \
--as user --format json
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--token` | 是 | 裸 token 或完整 URL。URL 路径支持 `/folder/``/docx/``/doc/``/sheets/``/base/``/bitable/``/wiki/``/file/``/mindnotes/``/slides/``/minutes/`。 |
| `--type` | 裸 token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`CLI 会拒绝。 |
| `--fields` | 否 | 默认不传。可取 `name` / `type` / `avatar` / `external_label`,支持逗号分隔;也可传 `*` 获取当前支持的所有附加字段。 |
| `--perm-type` | 否 | 仅 `--type wiki` 有效;取值 `container` / `single_page`。 |
| `--dry-run` | 否 | 只打印请求,不调用 API。 |
## 输出
JSON 输出原样透传 API 的 `data`
```json
{
"ok": true,
"identity": "user",
"data": {
"items": [
{
"member_type": "openid",
"member_id": "ou_xxx",
"perm": "view",
"perm_type": "container",
"type": "user",
"name": "zhangsan",
"external_label": false
}
]
}
}
```
`--format pretty` 会轻量展示成员 ID、成员类型、权限、wiki `perm_type` 和已返回的附加字段。机器读取优先使用 `--format json`
## 行为说明
- **身份支持**`--as user``--as bot` 均可用;缺 scope 或目标权限时按统一 permission 错误路径处理。
- **所需 scope**`docs:permission.member:retrieve`
- **fields 默认**:不传 `--fields` 时按官方 API 默认,不请求姓名、头像、外部标签等附加字段;需要时显式指定。
- **folder 支持**CLI 支持 `--type folder` 并会按需求发送 `type=folder`;部分环境的后端如果尚未放开 folder 枚举,可能返回 `99992402 field validation failed`

View File

@@ -28,7 +28,7 @@ lark-cli drive +secure-label-list --page-size 10 --lang zh
```bash
lark-cli drive +secure-label-update \
--token "https://example.feishu.cn/docx/doxcnxxxx" \
--label-id '<label-id>' # replace $LABEL_ID before running
--label-id "7217780879644737539"
```
参数:

View File

@@ -25,16 +25,16 @@ lark-cli drive +inspect --url '<url>' --as user --format json
lark-cli wiki +node-list \
--space-id '<space_id>' --page-size 50 \
--page-all --page-limit 0 \
--as user --format json # replace $SPACE_ID before running
--as user --format json
lark-cli wiki +node-list \
--space-id '<space_id>' --parent-node-token '<node_token>' --page-size 50 \
--page-all --page-limit 0 \
--as user --format json # replace $SPACE_ID before running
--as user --format json
lark-cli wiki +node-list \
--space-id '<space_id>' --page-token '<PAGE_TOKEN>' --page-size 50 \
--as user --format json # replace $SPACE_ID before running
--as user --format json
```
解析返回时使用 `data.nodes`,不要读取顶层 `items``--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。
@@ -69,18 +69,6 @@ lark-cli drive permission.public get \
--as user --format json
```
按需读取直接协作者/授权成员列表:
```bash
lark-cli drive +member-list \
--token '<token_or_url>' \
--type '<type>' \
--fields 'name,type,external_label' \
--as user --format json
```
`--fields` 默认不传;只有需要名称、协作者类型、头像或外部标签时才显式传。该命令读取的是当前目标的直接协作者/授权成员列表,不代表完整继承链或历史权限变更审计。
按需读取访问统计:
```bash
@@ -172,9 +160,9 @@ lark-cli drive +secure-label-list \
```bash
lark-cli drive +secure-label-update \
--token '<url>' \
--label-id '<label-id>' --as user --format json # replace $LABEL_ID before running
--label-id '<label-id>' --as user --format json
lark-cli drive +secure-label-update \
--token '<bare-token>' --type '<type>' \
--label-id '<label-id>' --as user --format json # replace $LABEL_ID before running
--label-id '<label-id>' --as user --format json
```

View File

@@ -42,7 +42,7 @@ Risk / Structure: `R2` / `S2`
- 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。
- 未按范围确认的批量写入。
协作者列表读取只覆盖当前目标的直接协作者/授权成员:可使用 `drive +member-list`
不要声称已完成协作者列表验证:当前 CLI surface 没有 `permission.members list` shortcut
## Progressive Load Map
@@ -96,7 +96,6 @@ Risk / Structure: `R2` / `S2`
| `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 |
| `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata |
| `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 |
| `FACT_READ` | `drive +member-list` | 读取用户显式要求的单目标直接协作者/授权成员列表;不代表完整继承链或历史权限审计 |
| `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 |
| `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 |
| `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs |
@@ -195,7 +194,7 @@ Drive folder 发现:
- `drive permission.members create` 可创建协作者权限,但当前 workflow 不做协作者 grant / update / revoke未来需要单独定义授权对象解析、最小权限、确认模板和验证方式。
- backup owner、部门 / 项目负责人绑定没有当前 workflow 可执行写入面;如用户要落地为 owner 转移,必须先给出明确目标和新 owner并走本 workflow 的 owner-transfer 确认。
- `wiki +member-list` 可作为 Wiki space 成员治理的读侧事实来源;当前 workflow 只治理文档 / 节点 / 文件夹下可发现文档的权限,不做 space member governance。
- `drive +member-list` 可读取单目标直接协作者/授权成员;当前 CLI 仍没有完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
- 当前 CLI 没有 `permission.members list`完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
## 输出策略

View File

@@ -19,7 +19,7 @@ lark-cli wiki +node-get \
|------|------|----------|---------|-------------|
| `--node-token` | string | **Yes** | — | `node_token`, cloud-doc `obj_token`, or a Lark URL embedding one (e.g. `https://feishu.cn/wiki/<token>` or `https://feishu.cn/docx/<token>`). Matches the `--node-token` naming used by sibling `+node-delete` / `+node-copy` / `+move`. |
| `--token` | string | — (deprecated) | — | Deprecated original name; still accepted for backward compatibility but emits a `Flag --token has been deprecated, use --node-token instead` warning on stderr. New scripts should use `--node-token`. |
| `--obj-type` | enum | No | — | Needed when `--node-token` is a raw `obj_token`; auto-inferred from typed Lark URLs. If omitted for a raw token, the shortcut treats it as a wiki `node_token`. |
| `--obj-type` | enum | No | — | Needed when `--node-token` is a raw `obj_token`; auto-inferred from the URL path. Not allowed when the token looks like a `node_token` (`wik...`) |
| `--space-id` | string | No | — | Optional cross-check: fail if the resolved node does not live in this space |
| `--format` | enum | No | `json` | `json` / `pretty` / `table` / `csv` / `ndjson` |
| `--as` | enum | No | `auto` | Identity `user`/`bot`; wiki is user-centric → pass `--as user` |

View File

@@ -15,11 +15,11 @@ import (
)
// TestAppsDBExecuteDryRun pins +db-execute 复用存量 URLCLI 永远走 DBA 模式
// ?transactional=falsesql body 由 --sql 透传,默认不传 env(空值,由服务端按 workspace 定分支)
// ?transactional=falsesql body 由 --sql 透传,默认 env=dev
func TestAppsDBExecuteDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultEnvUnsetAndTransactionalFalse", func(t *testing.T) {
t.Run("DefaultEnvIsDevAndTransactionalFalse", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
@@ -37,8 +37,8 @@ func TestAppsDBExecuteDryRun(t *testing.T) {
"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)")
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String(),
"default env must be dev (not production)")
})
t.Run("OnlineEnvSwitch", func(t *testing.T) {

View File

@@ -19,7 +19,7 @@ import (
func TestAppsDBTableListDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultsToNoEnvAndPageSize20", func(t *testing.T) {
t.Run("DefaultsToDevAndPageSize20", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
@@ -32,8 +32,7 @@ func TestAppsDBTableListDryRun(t *testing.T) {
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, "dev", gjson.Get(result.Stdout, "api.0.params.env").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")

View File

@@ -1,165 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_MemberListDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
wantURL string
wantType string
wantFields string
wantPermType string
}{
{
name: "bare folder token",
args: []string{
"drive", "+member-list",
"--token", "fldE2E001",
"--type", "folder",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/fldE2E001/members",
wantType: "folder",
},
{
name: "folder URL infers folder type",
args: []string{
"drive", "+member-list",
"--token", "https://example.feishu.cn/drive/folder/fldE2E002?from=share",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/fldE2E002/members",
wantType: "folder",
},
{
name: "fields star is passed only when explicit",
args: []string{
"drive", "+member-list",
"--token", "doxE2E003",
"--type", "docx",
"--fields", "*",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxE2E003/members",
wantType: "docx",
wantFields: "*",
},
{
name: "wiki perm type",
args: []string{
"drive", "+member-list",
"--token", "wikE2E004",
"--type", "wiki",
"--fields", "name,type",
"--perm-type", "single_page",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/wikE2E004/members",
wantType: "wiki",
wantFields: "name,type",
wantPermType: "single_page",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantType {
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
}
if tt.wantFields == "" {
if gjson.Get(out, "api.0.params.fields").Exists() {
t.Fatalf("params.fields should be omitted\nstdout:\n%s", out)
}
} else if got := gjson.Get(out, "api.0.params.fields").String(); got != tt.wantFields {
t.Fatalf("params.fields = %q, want %q\nstdout:\n%s", got, tt.wantFields, out)
}
if tt.wantPermType == "" {
if gjson.Get(out, "api.0.params.perm_type").Exists() {
t.Fatalf("params.perm_type should be omitted\nstdout:\n%s", out)
}
} else if got := gjson.Get(out, "api.0.params.perm_type").String(); got != tt.wantPermType {
t.Fatalf("params.perm_type = %q, want %q\nstdout:\n%s", got, tt.wantPermType, out)
}
})
}
}
func TestDrive_MemberListWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
folderName := "lark-cli-e2e-drive-member-list-" + clie2e.GenerateSuffix()
folderToken := createDriveFolderOrSkipPermission(t, parentT, ctx, folderName)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+member-list",
"--token", folderToken,
"--type", "folder",
"--format", "json",
},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr)
if strings.Contains(combinedOutput, "docs:permission.member:retrieve") ||
strings.Contains(combinedOutput, "app scope not enabled") ||
strings.Contains(combinedOutput, "missing required scope") ||
strings.Contains(combinedOutput, "missing_scope") ||
strings.Contains(combinedOutput, "99991672") ||
strings.Contains(combinedOutput, "1063002") ||
strings.Contains(combinedOutput, "1063004") ||
strings.Contains(combinedOutput, "permission denied") ||
strings.Contains(combinedOutput, "no share permission") {
t.Skipf("skip drive member list workflow due to missing bot scope or folder permission: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
}
if strings.Contains(combinedOutput, "99992402") &&
strings.Contains(combinedOutput, "field validation failed") {
t.Skipf("skip drive member list workflow because this environment does not yet accept type=folder on the member list API: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
}
t.Fatalf("drive member list workflow failed: exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
}
result.AssertStdoutStatus(t, true)
if items := gjson.Get(result.Stdout, "data.items"); !items.Exists() || !items.IsArray() {
t.Fatalf("data.items must be present as an array\nstdout:\n%s", result.Stdout)
}
}

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/tidwall/gjson"
)
// seededCatalogVersion is far newer than the embedded stub's 0.0.0, so the
// runtime overlay in internal/registry unconditionally applies it.
const seededCatalogVersion = "9.9.9"
// seededCatalogJSON is a remote_meta.json (registry.MergedRegistry) carrying one
// obviously-synthetic service. Seeding it into a bare-module fork's on-disk cache
// gives the runtime catalog real data WITHOUT any network, so a test can prove
// SchemaCatalog() consults that runtime catalog (issue #1764) rather than the
// embedded-only (empty stub) catalog. Fields mirror internal/meta.Service.
const seededCatalogJSON = `{
"version": "9.9.9",
"services": [
{
"name": "plugine2e",
"version": "v1",
"title": "plugin_e2e synthetic service",
"description": "synthetic fixture for the runtime-catalog test; not a real API",
"servicePath": "/open-apis/plugine2e/v1",
"resources": {
"widgets": {
"methods": {
"get": {
"id": "plugine2e.widgets.get",
"path": "/open-apis/plugine2e/v1/widgets/:id",
"httpMethod": "GET",
"description": "synthetic read method",
"risk": "read",
"accessTokens": ["tenant"],
"parameters": {
"id": {"type": "string", "location": "path", "required": true, "description": "synthetic id"}
}
}
}
}
}
}
]
}`
// runWithSeededCatalog runs bin against a fresh LARKSUITE_CLI_CONFIG_DIR whose
// cache already holds cacheJSON as remote_meta.json (plus a fresh, high-version
// cache-meta so the overlay applies and the TTL never triggers a refetch). Remote
// meta is left ON so the on-disk cache overlay is consulted, but a long
// LARKSUITE_CLI_META_TTL keeps the run offline and deterministic. This models a
// bare-module binary that has runtime metadata available from a warm cache.
func runWithSeededCatalog(t *testing.T, bin, cacheJSON string, args ...string) result {
t.Helper()
cfg := t.TempDir()
cacheDir := filepath.Join(cfg, "cache")
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
t.Fatalf("mkdir cache dir: %v", err)
}
writeFile(t, filepath.Join(cacheDir, "remote_meta.json"), cacheJSON)
writeFile(t, filepath.Join(cacheDir, "remote_meta.meta.json"),
fmt.Sprintf(`{"last_check_at":%d,"version":%q,"brand":""}`, time.Now().Unix(), seededCatalogVersion))
env := append(os.Environ(),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
"LARKSUITE_CLI_CONFIG_DIR="+cfg,
"LARKSUITE_CLI_META_TTL=1000000",
)
return runWithEnv(t, bin, env, args...)
}
// plainPlugin registers a minimal observer-only plugin with NO Restrict rule
// -- unlike readonly_test.go's plugins, it cannot deny "schema" as
// out-of-domain, so any failure the command produces below is the command's
// own behavior against the empty stub catalog, not a policy denial.
const plainPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("plain", "0.1.0").
Observer(platform.After, "noop", platform.All(),
func(_ context.Context, _ platform.Invocation) {}).
FailOpen().
MustBuild())
}
`
// TestDegradeStubMetadataSchema pins the #1764 stub-metadata degrade path.
// The clean tree embeds only the empty meta_data_default.json stub
// (internal/registry/catalog.go's SchemaCatalog falls through to
// RuntimeCatalog when EmbeddedServicesTyped() is empty), and run()'s isolated
// environment disables the remote overlay fetch and points the cache dir at
// an empty tmp dir, so cmd/schema/schema.go's runSchema sees
// catalog.Services() == 0 unconditionally -- the exact "offline with a cold
// cache, remote meta off" branch documented at cmd/schema/schema.go:96-101.
//
// Observed real output for both `schema` and `schema im.messages.reply`
// (identical -- runSchema checks catalog.Services()==0 before parsing args):
//
// exit=2
// stdout=(empty)
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"No API metadata available",
// "hint":"this binary has no embedded API metadata; run any command with
// network access to the open platform once so metadata can be fetched and
// cached"}}
//
// This is the PINNED "graceful degrade" criterion: a structured JSON envelope
// (gjson.Valid, no "panic:" substring) carrying a validation/failed_precondition
// error with an actionable hint, NOT the raw Go panic crash that
// install_test.go's TestInstallMustBuildInitPanicCrashesBinary pins for a
// genuinely broken plugin, and NOT an "Unknown"-shaped internal error.
// Note: exit==2 alone does not prove "not a crash" -- a genuine Go panic also
// exits 2. The two real discriminators against a crash are the absence of a
// "panic:" substring in stderr and stderr being valid JSON (gjson.Valid); both
// are asserted below.
func TestDegradeStubMetadataSchema(t *testing.T) {
bin := buildFork(t, "plain", plainPlugin)
cases := []struct {
name string
args []string
}{
{"schema root", []string{"schema"}},
{"schema with path", []string{"schema", "im.messages.reply"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := run(t, bin, tc.args...)
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
if res.exit != 2 {
t.Fatalf("exit=%d want 2 (graceful validation exit); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if strings.Contains(res.stderr, "panic:") {
t.Fatalf("stderr contains a raw Go panic trace, not a graceful degrade; stderr=%s", res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not a structured JSON envelope: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "No API metadata available" {
t.Errorf("error.message=%q want %q", msg, "No API metadata available")
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "no embedded API metadata") {
t.Errorf("error.hint=%q want to contain %q", hint, "no embedded API metadata")
}
})
}
}
// TestRuntimeCatalogResolvesSchema pins the PRIMARY #1764 fix: a bare-module fork
// (embedded stub only) resolves `schema` against the RUNTIME catalog seeded from
// the on-disk cache, not the embedded-only catalog. Before f0b6f35f the module
// build read the embedded-only catalog and returned "Unknown service: <svc>" even
// though the runtime registry had metadata; after it, registry.SchemaCatalog()
// falls back to the merged runtime catalog and the lookup succeeds.
//
// This is the counterpart to TestDegradeStubMetadataSchema: that test pins the
// cold-cache corner (no runtime data -> graceful "No API metadata available");
// this one pins the warm-cache main path (runtime data present -> schema works),
// so a regression that re-embeds the embedded-only lookup fails HERE with
// "Unknown service" rather than silently passing.
func TestRuntimeCatalogResolvesSchema(t *testing.T) {
bin := buildFork(t, "plain", plainPlugin)
res := runWithSeededCatalog(t, bin, seededCatalogJSON, "schema", "plugine2e")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
out := res.stdout + res.stderr
if strings.Contains(out, "Unknown service") {
t.Fatalf("schema returned \"Unknown service\" -> runtime catalog NOT consulted (issue #1764 regression); out=%s", out)
}
if strings.Contains(out, "No API metadata available") {
t.Fatalf("schema saw no metadata -> the seeded runtime cache was not loaded; out=%s", out)
}
if res.exit != 0 {
t.Fatalf("exit=%d want 0 (schema resolved from runtime catalog); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !strings.Contains(out, "plugine2e") {
t.Errorf("schema output does not mention the seeded service; out=%s", out)
}
}
// credentialBlockPlugin registers a credential.Provider whose ResolveAccount
// (and ResolveToken) unconditionally return a *credential.BlockError.
// internal/credential/credential_provider.go's doResolveAccount returns this
// error straight from the provider loop -- before any defaultAcct fallback
// and, transitively, before the LarkClient/HttpClient phases that would issue
// a real network call ever run (see internal/cmdutil/factory_default.go's
// Phase 2 -> Phase 4 ordering).
const credentialBlockPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/credential"
)
type blockProvider struct{}
func (blockProvider) Name() string { return "block-cred" }
func (blockProvider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
return nil, &credential.BlockError{Provider: "block-cred", Reason: "blocked for test"}
}
func (blockProvider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
return nil, &credential.BlockError{Provider: "block-cred", Reason: "blocked for test"}
}
func init() {
credential.Register(blockProvider{})
}
`
// TestSubsystemCredentialBlock pins the credential.BlockError offline effect.
// Observed real output for `docs +fetch --doc nonexistent`, run twice across
// separate `go test -count` invocations (byte-identical both times, unlike
// the transport-abort case -- credential resolution happens once, before any
// endpoint is chosen, so there is no varying destination URL to leak into the
// message):
//
// exit=5
// stdout=(empty)
// stderr={"ok":false,"identity":"bot","error":{"type":"internal","subtype":"unknown",
// "message":"blocked by block-cred: blocked for test"}}
func TestSubsystemCredentialBlock(t *testing.T) {
bin := buildFork(t, "credential-block", credentialBlockPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
if res.exit != 5 {
t.Fatalf("exit=%d want 5; stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "internal" {
t.Errorf("error.type=%q want internal", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "unknown" {
t.Errorf("error.subtype=%q want unknown", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "blocked by block-cred: blocked for test" {
t.Errorf("error.message=%q want %q", msg, "blocked by block-cred: blocked for test")
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"testing"
"github.com/tidwall/gjson"
)
// TestDiagnostics asserts the VERIFIED stdout shapes of the two policy/plugin
// diagnostic commands on a fork carrying the readonly Restrict rule:
// - `config policy show`: source_name == the plugin name that installed the
// active rule.
// - `config plugins show`: {"plugins":[{"name","version","capabilities",...,
// "hooks":{...}}],"total":N} with the readonly plugin present.
func TestDiagnostics(t *testing.T) {
bin := buildFork(t, "readonly", readonlyPlugin)
pol := run(t, bin, "config", "policy", "show")
if pol.exit != 0 || !gjson.Valid(pol.stdout) {
t.Fatalf("policy show exit=%d stdout=%s stderr=%s", pol.exit, pol.stdout, pol.stderr)
}
if src := gjson.Get(pol.stdout, "source_name").String(); src != "readonly" {
t.Errorf("policy source_name=%q want readonly (stdout=%s)", src, pol.stdout)
}
plug := run(t, bin, "config", "plugins", "show")
if plug.exit != 0 || !gjson.Valid(plug.stdout) {
t.Fatalf("plugins show exit=%d stdout=%s", plug.exit, plug.stdout)
}
if total := gjson.Get(plug.stdout, "total").Int(); total < 1 {
t.Errorf("plugins total=%d want >=1 (stdout=%s)", total, plug.stdout)
}
if name := gjson.Get(plug.stdout, "plugins.0.name").String(); name != "readonly" {
t.Errorf("plugins.0.name=%q want readonly", name)
}
}

210
tests/plugin_e2e/harness.go Normal file
View File

@@ -0,0 +1,210 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package plugin_e2e exercises the extension/platform plugin contract the way a
// real customer does: it builds a fork of lark-cli with a plugin blank-imported,
// then runs that fork as a subprocess and asserts the real stderr/stdout
// envelopes and exit codes. This is L4 coverage — the in-process unit and
// integration tests (extension/..., cmd/...) assert Go error values in the test
// process and structurally cannot observe envelope serialization, exit codes, or
// the blank-import -> init -> Register -> InstallAll assembly chain.
//
// Mechanism (the "customer build", mirrors xcaddy's build mode):
// 1. `git archive HEAD` a clean tree containing only committed files (so the
// fork embeds the tracked meta_data stub, reproducing the bare-module state).
// 2. Generate a customer module: go.mod (cli's requires + `replace` to the
// archived tree) + go.sum copy + main.go (blank-imports the plugin package)
// + plugin package (its init() calls platform.Register).
// 3. `go build` the fork (offline-capable via the warm module cache), then run
// it as a subprocess and assert.
package plugin_e2e
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// cleanTree is the git-archived, committed-only source tree of the repo under
// test, shared by every fork build. Populated by TestMain (smoke_test.go) —
// TestMain must live in a _test.go file to be recognized by `go test`, so the
// entry point sits there while the rest of the harness mechanism lives here.
var cleanTree string
// baseDir holds the archive tree plus every generated customer module.
var baseDir string
// repoRoot resolves the lark-cli module root from the test's working directory
// (which `go test` sets to the package dir, tests/plugin_e2e).
func repoRoot() (string, error) {
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// gitArchive extracts HEAD's committed tree into dst by streaming `git archive`
// into `tar -x`. Only tracked files are included — gitignored build artifacts
// (e.g. the fetched meta_data.json) are absent, exactly as a module consumer
// would see them. It wires the two processes with an explicit pipe rather than a
// shell, so dst never reaches a shell command line.
func gitArchive(root, dst string) error {
archive := exec.Command("git", "archive", "HEAD")
archive.Dir = root
extract := exec.Command("tar", "-x", "-C", dst)
pipe, err := archive.StdoutPipe()
if err != nil {
return err
}
extract.Stdin = pipe
// Each process gets its own stderr buffer: os/exec spawns a copy goroutine
// per command, so a shared strings.Builder would be written concurrently by
// both (git archive and tar run in parallel) -- a data race, since
// strings.Builder is not concurrency-safe.
var archiveErr, extractErr strings.Builder
archive.Stderr = &archiveErr
extract.Stderr = &extractErr
if err := extract.Start(); err != nil {
return err
}
if err := archive.Run(); err != nil {
_ = extract.Wait()
return fmt.Errorf("git archive: %w: %s", err, archiveErr.String())
}
if err := extract.Wait(); err != nil {
return fmt.Errorf("tar extract: %w: %s", err, extractErr.String())
}
return nil
}
// builtForks caches fork binaries by name so identical forks are built once.
var builtForks = map[string]string{}
// buildFork generates a customer module whose plugin package body is pluginSrc,
// builds the fork, and returns the binary path. Forks are cached by name.
func buildFork(t *testing.T, name, pluginSrc string) string {
t.Helper()
if bin, ok := builtForks[name]; ok {
return bin
}
mod := filepath.Join(baseDir, "fork-"+name)
if err := os.MkdirAll(filepath.Join(mod, "plugin"), 0o755); err != nil {
t.Fatalf("mkdir customer module: %v", err)
}
// go.mod: reuse cli's require graph, rename the module, replace cli with the
// local archived tree. This avoids `go mod tidy` (no network at test time).
rawMod, err := os.ReadFile(filepath.Join(cleanTree, "go.mod"))
if err != nil {
t.Fatalf("read archived go.mod: %v", err)
}
gomod := strings.Replace(string(rawMod), "module github.com/larksuite/cli", "module larkcustomer", 1)
gomod += "\nrequire github.com/larksuite/cli v0.0.0\n\nreplace github.com/larksuite/cli => " + cleanTree + "\n"
writeFile(t, filepath.Join(mod, "go.mod"), gomod)
// go.sum: transitive dependency hashes are identical to cli's.
rawSum, err := os.ReadFile(filepath.Join(cleanTree, "go.sum"))
if err != nil {
t.Fatalf("read archived go.sum: %v", err)
}
writeFile(t, filepath.Join(mod, "go.sum"), string(rawSum))
writeFile(t, filepath.Join(mod, "main.go"), customerMain)
writeFile(t, filepath.Join(mod, "plugin", "plugin.go"), pluginSrc)
bin := filepath.Join(mod, "fork-bin")
build := exec.Command("go", "build", "-o", bin, ".")
build.Dir = mod
// -mod=mod fixes require annotations copied from cli's go.mod; the default
// GOPROXY resolves any dep missing from the cache (goproxy in CI/dev).
build.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build fork %q failed: %v\n%s", name, err, out)
}
builtForks[name] = bin
return bin
}
const customerMain = `// Code generated by plugin_e2e; DO NOT EDIT.
package main
import (
"os"
"github.com/larksuite/cli/cmd"
_ "larkcustomer/plugin" // blank import triggers plugin init() -> platform.Register
)
func main() { os.Exit(cmd.Execute()) }
`
// result is a subprocess run outcome.
type result struct {
stdout string
stderr string
exit int
}
// run executes the fork binary with args in an isolated, offline environment and
// captures stdout/stderr/exit. Each call gets a fresh empty
// LARKSUITE_CLI_CONFIG_DIR and LARKSUITE_CLI_REMOTE_META=off, so the fork never
// inherits the host's ~/.lark-cli cache or makes a startup metadata fetch to the
// open platform. That reproduces the bare-module customer state (no embedded
// metadata, cold cache) deterministically on any machine, including CI: without
// it, whether a command's assertion is reached depends on whether a live network
// fetch happened to succeed. Tests that need runtime metadata seed it explicitly
// via runWithSeededCatalog.
func run(t *testing.T, bin string, args ...string) result {
t.Helper()
return runWithEnv(t, bin, isolatedEnv(t), args...)
}
// isolatedEnv is the bare-module, offline environment shared by run() and (as a
// base) by runWithSeededCatalog.
func isolatedEnv(t *testing.T) []string {
t.Helper()
return append(os.Environ(),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
"LARKSUITE_CLI_REMOTE_META=off",
)
}
// runWithEnv runs bin as a subprocess with the given full environment, capturing
// stdout/stderr/exit.
func runWithEnv(t *testing.T, bin string, env []string, args ...string) result {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
c := exec.CommandContext(ctx, bin, args...)
c.Env = env
var stdout, stderr strings.Builder
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
exit := 0
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
exit = ee.ExitCode()
} else {
t.Fatalf("run %v: %v", args, err)
}
}
return result{stdout: stdout.String(), stderr: stderr.String(), exit: exit}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}

View File

@@ -0,0 +1,339 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
// multipleRestrictPlugin registers TWO distinct plugins that each call
// Restrict() with an independently valid Rule. cmdpolicy.Resolve rejects
// more than one distinct Restrict-owner regardless of each rule's own
// validity (internal/cmdpolicy/resolver.go's distinctOwners check runs
// before ValidateRule).
const multipleRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("restrict-a", "0.1.0").
Restrict(&platform.Rule{
Name: "a-rule",
Allow: []string{"docs/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
platform.Register(
platform.NewPlugin("restrict-b", "0.1.0").
Restrict(&platform.Rule{
Name: "b-rule",
Allow: []string{"im/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// TestInstallMultipleRestrictPluginsPin pins reason_code=multiple_restrict_plugins.
// Observed real output (any command, e.g. "schema" -- the fatal guard walks
// every RunE in the tree so it fires regardless of which command runs):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"multiple plugins called Restrict; only one plugin may own the
// policy: [restrict-a restrict-b]",
// "hint":"plugin policy configuration is broken (reason_code
// multiple_restrict_plugins); fix the plugin's Restrict rule or remove the
// conflicting plugin"}}
func TestInstallMultipleRestrictPluginsPin(t *testing.T) {
bin := buildFork(t, "multiple-restrict", multipleRestrictPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "multiple_restrict_plugins")
}
// invalidRulePlugin registers a single plugin whose Restrict Rule carries a
// syntactically-invalid MaxRisk value. Neither the Builder nor the staging
// Registrar validate Rule *contents* (only nilness) -- semantic validation
// happens later, in cmdpolicy.ValidateRule, called from
// cmd/platform_bootstrap.go's applyUserPolicyPruning -> cmdpolicy.Resolve.
const invalidRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("invalid-rule", "0.1.0").
Restrict(&platform.Rule{
Name: "bad-risk",
Allow: []string{"docs/**"},
MaxRisk: platform.Risk("bogus"),
}).
MustBuild())
}
`
// TestInstallInvalidRulePin pins reason_code=invalid_rule. Observed real output
// (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"invalid-rule\" rule invalid: invalid max_risk \"bogus\":
// must be one of read|write|high-risk-write",
// "hint":"plugin policy configuration is broken (reason_code invalid_rule);
// fix the plugin's Restrict rule or remove the conflicting plugin"}}
func TestInstallInvalidRulePin(t *testing.T) {
bin := buildFork(t, "invalid-rule", invalidRulePlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "invalid_rule")
}
// installFailedPlugin is a hand-written bare platform.Plugin (not
// Builder-based -- Install returning a plain error is not expressible
// through the Builder's fluent API) whose Install always returns an error.
// FailurePolicy=FailClosed makes the host abort rather than warn+skip.
const installFailedPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"errors"
"github.com/larksuite/cli/extension/platform"
)
type installFailed struct{}
func (installFailed) Name() string { return "install-failed" }
func (installFailed) Version() string { return "0.1.0" }
func (installFailed) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (installFailed) Install(r platform.Registrar) error {
return errors.New("deliberate install failure")
}
func init() { platform.Register(installFailed{}) }
`
// TestInstallFailedPin pins reason_code=install_failed. Observed real output
// (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"install-failed\" (install_failed): Install returned
// error: deliberate install failure",
// "hint":"plugin \"install-failed\" failed to install (reason_code
// install_failed); fix or remove the plugin before running commands"}}
func TestInstallFailedPin(t *testing.T) {
bin := buildFork(t, "install-failed", installFailedPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "install_failed")
}
// installPanicPlugin is a hand-written bare Plugin whose Install panics.
// safeCallInstall (internal/platform/host.go) recovers and converts the
// panic into a typed install_panic error rather than crashing the binary.
const installPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type installPanic struct{}
func (installPanic) Name() string { return "install-panic" }
func (installPanic) Version() string { return "0.1.0" }
func (installPanic) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (installPanic) Install(r platform.Registrar) error {
panic("deliberate install panic")
}
func init() { platform.Register(installPanic{}) }
`
// TestInstallPanicPin pins reason_code=install_panic. Observed real output
// (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"install-panic\" (install_panic): Install panicked:
// deliberate install panic",
// "hint":"plugin \"install-panic\" failed to install (reason_code
// install_panic); fix or remove the plugin before running commands"}}
func TestInstallPanicPin(t *testing.T) {
bin := buildFork(t, "install-panic", installPanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "install_panic")
}
// pluginNamePanicPlugin is a hand-written bare Plugin whose Name() panics.
// InstallAll's outer loop calls safeCallName BEFORE it ever reads
// Capabilities(), so this aborts unconditionally regardless of what
// Capabilities() would have declared (host.go's isUntrustedConfigError
// list) -- Capabilities() here is a throwaway zero value, never invoked.
const pluginNamePanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type pluginNamePanic struct{}
func (pluginNamePanic) Name() string { panic("deliberate name panic") }
func (pluginNamePanic) Version() string { return "0.1.0" }
func (pluginNamePanic) Capabilities() platform.Capabilities {
return platform.Capabilities{}
}
func (pluginNamePanic) Install(r platform.Registrar) error { return nil }
func init() { platform.Register(pluginNamePanic{}) }
`
// TestInstallPluginNamePanicPin pins reason_code=plugin_name_panic. Observed real
// output (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"<unknown>\" (plugin_name_panic): Plugin.Name()
// panicked: deliberate name panic",
// "hint":"plugin \"<unknown>\" failed to install (reason_code
// plugin_name_panic); fix or remove the plugin before running commands"}}
func TestInstallPluginNamePanicPin(t *testing.T) {
bin := buildFork(t, "plugin-name-panic", pluginNamePanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "plugin_name_panic")
}
// capabilitiesPanicPlugin is a hand-written bare Plugin whose Capabilities()
// panics. readFailurePolicy (internal/platform/host.go) re-invokes
// Capabilities() to decide FailOpen vs FailClosed, panics again, and its
// recover leaves the pre-set FailClosed default in place -- so this aborts
// unconditionally too, without the plugin ever declaring a real policy.
const capabilitiesPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type capabilitiesPanic struct{}
func (capabilitiesPanic) Name() string { return "capabilities-panic" }
func (capabilitiesPanic) Version() string { return "0.1.0" }
func (capabilitiesPanic) Capabilities() platform.Capabilities {
panic("deliberate capabilities panic")
}
func (capabilitiesPanic) Install(r platform.Registrar) error { return nil }
func init() { platform.Register(capabilitiesPanic{}) }
`
// TestInstallCapabilitiesPanicPin pins reason_code=capabilities_panic. Observed
// real output (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"capabilities-panic\" (capabilities_panic):
// Plugin.Capabilities() panicked: deliberate capabilities panic",
// "hint":"plugin \"capabilities-panic\" failed to install (reason_code
// capabilities_panic); fix or remove the plugin before running commands"}}
func TestInstallCapabilitiesPanicPin(t *testing.T) {
bin := buildFork(t, "capabilities-panic", capabilitiesPanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "capabilities_panic")
}
// restrictsMismatchPlugin is a hand-written bare Plugin that declares
// Capabilities.Restricts=true (paired with the required FailClosed) but
// whose Install never calls r.Restrict. stagingRegistrar.validateSelf
// (internal/platform/staging.go) checks this exact declared-vs-actual
// consistency after Install returns.
const restrictsMismatchPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type restrictsMismatch struct{}
func (restrictsMismatch) Name() string { return "restricts-mismatch" }
func (restrictsMismatch) Version() string { return "0.1.0" }
func (restrictsMismatch) Capabilities() platform.Capabilities {
return platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed}
}
func (restrictsMismatch) Install(r platform.Registrar) error { return nil }
func init() { platform.Register(restrictsMismatch{}) }
`
// TestInstallRestrictsMismatchPin pins reason_code=restricts_mismatch.
// Observed real output (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"restricts-mismatch\" (restricts_mismatch):
// Capabilities.Restricts=true but Install did not call r.Restrict",
// "hint":"plugin \"restricts-mismatch\" failed to install (reason_code
// restricts_mismatch); fix or remove the plugin before running commands"}}
func TestInstallRestrictsMismatchPin(t *testing.T) {
bin := buildFork(t, "restricts-mismatch", restrictsMismatchPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "restricts_mismatch")
}
// mustBuildPanicPlugin calls MustBuild() on a Builder with an invalid plugin
// name ("BadName!!" fails ^[a-z0-9][a-z0-9-]*$). This panics from
// plugin.init(), which runs from the blank-import BEFORE main() has a
// chance to install any recover-and-envelope guard -- so, unlike every
// other case here, this crashes the process outright: no JSON envelope,
// non-zero exit, a raw Go panic trace on stderr.
const mustBuildPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(platform.NewPlugin("BadName!!", "0.1.0").MustBuild())
}
`
// TestInstallMustBuildInitPanicCrashesBinary pins the MustBuild init-panic crash
// shape. This is NOT the plugin_install envelope -- it is a bare Go panic
// crash, because it happens in init(), before main()'s recover guard
// exists. Observed real output (schema):
//
// exit=2
// stderr=panic: plugin "BadName!!": invalid plugin name "BadName!!": must
// match ^[a-z0-9][a-z0-9-]*$
//
// goroutine 1 [running]:
// larkcustomer/plugin.init.0(...)
// .../plugin/plugin.go:7
// ...
func TestInstallMustBuildInitPanicCrashesBinary(t *testing.T) {
bin := buildFork(t, "mustbuild-panic", mustBuildPanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
if res.exit == 0 {
t.Fatalf("expected non-zero exit on init panic; exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if gjson.Valid(res.stderr) {
t.Fatalf("expected a raw panic trace, not a JSON envelope; stderr=%s", res.stderr)
}
if !strings.Contains(res.stderr, "panic:") {
t.Fatalf("stderr missing Go panic trace; stderr=%s", res.stderr)
}
if !strings.Contains(res.stderr, `invalid plugin name "BadName!!"`) {
t.Errorf("stderr missing the Builder's invalid-name message; stderr=%s", res.stderr)
}
}

View File

@@ -0,0 +1,298 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
// auditPlugin registers a single After observer matching every command that
// logs "[audit] <path>" to stderr. Based on (a simplified form of) the
// shipped extension/platform/examples/audit-observer example.
const auditPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("audit", "0.1.0").
Observer(platform.After, "log", platform.All(),
func(_ context.Context, inv platform.Invocation) {
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
}).
FailOpen().
MustBuild())
}
`
// TestObservePin pins the audit observer's stderr line format. Observed
// real output (docs +fetch --doc nonexistent, a real read-risk command that
// fails downstream with an API error unrelated to the plugin):
//
// exit=1
// stderr=[audit] docs/+fetch
// {"ok":false,"identity":"user","error":{"type":"api","subtype":"unknown",...}}
//
// The observer line always leads, on its own line, before whatever the
// command itself writes to stderr.
func TestObservePin(t *testing.T) {
bin := buildFork(t, "audit", auditPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if !strings.Contains(res.stderr, "[audit] docs/+fetch\n") {
t.Fatalf("stderr missing audit line; stderr=%s", res.stderr)
}
}
// auditRestrictPlugin combines an After observer with a Restrict rule in one
// plugin, so a denied command's stderr carries both the observer's
// side-effect and the denial envelope: the framework's contract is that
// After observers fire even for denied commands (see
// extension/platform/invocation.go's DeniedByPolicy doc).
const auditRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("audit-restrict", "0.1.0").
Observer(platform.After, "log", platform.All(),
func(_ context.Context, inv platform.Invocation) {
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
}).
Restrict(&platform.Rule{
Name: "agent-readonly",
Allow: []string{"docs/**", "im/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// TestObserveOnDeniedPin pins the audit-contract case: a denied command's
// stderr carries BOTH the observer's audit line AND the denial envelope,
// concatenated in a single stream, audit line first. Observed real output
// (docs +update --doc-token x --content y, denied write_not_allowed):
//
// exit=2
// stderr=[audit] docs/+update
// {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",...}}
//
// The leading "[audit] ..." line means gjson.Valid on the raw stderr is
// false; the JSON envelope must be sliced out from the first '{' before
// parsing it as JSON.
func TestObserveOnDeniedPin(t *testing.T) {
bin := buildFork(t, "audit-restrict", auditRestrictPlugin)
res := run(t, bin, "docs", "+update", "--doc-token", "x", "--content", "y")
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !strings.Contains(res.stderr, "[audit] docs/+update\n") {
t.Fatalf("stderr missing audit line on a denied command; stderr=%s", res.stderr)
}
i := strings.Index(res.stderr, "{")
if i < 0 {
t.Fatalf("stderr has no JSON envelope after the audit line; stderr=%s", res.stderr)
}
envelope := res.stderr[i:]
if !gjson.Valid(envelope) {
t.Fatalf("sliced envelope not JSON: %s", envelope)
}
if got := gjson.Get(envelope, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(envelope, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if hint := gjson.Get(envelope, "error.hint").String(); !strings.Contains(hint, "reason_code write_not_allowed") {
t.Errorf("hint=%q want to contain reason_code write_not_allowed", hint)
}
}
// observerPanicPlugin's After observer panics unconditionally. runObserverSafe
// (internal/hook/install.go) must isolate the panic so command dispatch
// still completes normally.
const observerPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("observer-panic", "0.1.0").
Observer(platform.After, "log", platform.All(),
func(_ context.Context, _ platform.Invocation) {
panic("boom")
}).
FailOpen().
MustBuild())
}
`
// TestObserverPanicIsolationPin pins panic isolation: an After observer that
// always panics must not affect the command's own outcome. The assertion is
// baseline-relative -- the panicking-observer fork's exit code must equal the
// noop-observer baseline fork's for the same `schema` command (a local,
// network-free, read-risk command), whatever that shared exit code is.
// Observed real output at pin time:
//
// panicking: exit=0 stderr=warning: hook "observer-panic.log" panicked: boom
// baseline: exit=0 stderr=(empty)
//
// The panic is fully swallowed by runObserverSafe (internal/hook/install.go),
// surfacing only as a stderr warning line, never as a non-zero exit or crash.
func TestObserverPanicIsolationPin(t *testing.T) {
bin := buildFork(t, "observer-panic", observerPanicPlugin)
res := run(t, bin, "schema")
baselineBin := buildFork(t, "smoke", noopPlugin)
baseline := run(t, baselineBin, "schema")
if res.exit != baseline.exit {
t.Fatalf("panicking-observer exit=%d differs from baseline exit=%d; stderr=%s", res.exit, baseline.exit, res.stderr)
}
if !strings.Contains(res.stderr, `warning: hook "observer-panic.log" panicked: boom`) {
t.Errorf("stderr missing panic-isolation warning; stderr=%s", res.stderr)
}
}
// wrapAbortPlugin's Wrapper short-circuits every command with an AbortError
// instead of calling next.
const wrapAbortPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("wrap-abort", "0.1.0").
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
return &platform.AbortError{
HookName: "guard",
Reason: "blocked for test",
}
}
}).
FailOpen().
MustBuild())
}
`
// TestWrapAbortPin pins the wrap-abort envelope shape. An *AbortError
// returned by a Wrapper is converted by wrapAbortError
// (internal/hook/install.go) into the SAME envelope shape as a Restrict
// denial -- error.type=="validation", error.subtype=="failed_precondition"
// -- NOT a distinct "hook" error type. Observed real output (docs +fetch
// --doc nonexistent, wrapper aborts unconditionally before calling next):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"hook \"wrap-abort.guard\" aborted: blocked for test",
// "hint":"plugin hook \"wrap-abort.guard\" aborted this command; adjust the
// request to satisfy the hook's policy, or remove the plugin"}}
//
// HookName is namespaced to "<plugin-name>.<hookName>" ("wrap-abort.guard")
// regardless of the HookName the plugin set on the AbortError itself
// (namespacedWrap overwrites it) -- see internal/hook/install.go.
func TestWrapAbortPin(t *testing.T) {
bin := buildFork(t, "wrap-abort", wrapAbortPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-abort.guard" aborted: blocked for test`) {
t.Errorf("error.message=%q want to contain the namespaced hook name and Reason", msg)
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-abort.guard" aborted this command`) {
t.Errorf("error.hint=%q want to contain the abort hint", hint)
}
}
// wrapPanicPlugin's Wrapper factory panics on every invocation (the factory
// closure itself, not the returned Handler).
const wrapPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("wrap-panic", "0.1.0").
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
panic("wrap boom")
}).
FailOpen().
MustBuild())
}
`
// TestWrapPanicPin pins the wrap-panic envelope shape: a panicking Wrapper
// factory does not crash the process. recoverWrap (internal/hook/install.go)
// converts the panic into the same validation/failed_precondition shape as
// wrap-abort, with a distinct message/hint pair. Observed real output (docs
// +fetch --doc nonexistent, wrapper factory panics unconditionally):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"hook \"wrap-panic.guard\" panicked: wrap boom",
// "hint":"plugin hook \"wrap-panic.guard\" crashed while handling this
// command; report the panic to the plugin author or remove the plugin"}}
func TestWrapPanicPin(t *testing.T) {
bin := buildFork(t, "wrap-panic", wrapPanicPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-panic.guard" panicked: wrap boom`) {
t.Errorf("error.message=%q want to contain the namespaced hook name and panic value", msg)
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-panic.guard" crashed while handling this command`) {
t.Errorf("error.hint=%q want to contain the panic hint", hint)
}
}

View File

@@ -0,0 +1,205 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
// readonlyPlugin registers a Restrict rule that only allows read-risk
// commands under the docs/** and im/** domains. It mirrors the official
// example readonly-policy configuration.
const readonlyPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("readonly", "0.1.0").
Restrict(&platform.Rule{
Name: "agent-readonly",
Allow: []string{"docs/**", "im/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// TestReadonlyDenial asserts the VERIFIED denial envelope shape: stderr is
// valid JSON, error.type=="validation", error.subtype=="failed_precondition",
// error.hint contains the literal "reason_code <X>" substring, and the
// process exits 2. reason_code lives only in the hint string, not a
// structured field.
func TestReadonlyDenial(t *testing.T) {
bin := buildFork(t, "readonly", readonlyPlugin)
// Note: reason_code mixed_children_policy is intentionally NOT covered here.
// It requires a parent command whose *enumerated children* have mixed
// allow/deny outcomes, which needs the full command tree from API metadata.
// This L4 harness builds a bare-module fork (embedded stub only), so offline
// a parent like "sheets" has no known children and collapses to
// domain_not_allowed -- identical to the "leaf out of allow list" case and
// not a distinct reason_code. Covered instead by the in-process cmdpolicy
// unit tests, which construct a mixed-children tree directly.
cases := []struct {
name string
args []string
reasonCode string
}{
{"write in allowed domain", []string{"docs", "+update", "--doc-token", "x", "--content", "y"}, "write_not_allowed"},
{"leaf out of allow list", []string{"schema"}, "domain_not_allowed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertReasonCodeEnvelope(t, run(t, bin, tc.args...), tc.reasonCode)
})
}
}
// TestReadonlyAllows asserts the allow-path: a read command inside an
// allowed domain must NOT be denied by the policy gate. It may still fail
// downstream (e.g. api/auth error), but that failure must not carry the
// denial envelope shape and must not exit 2.
func TestReadonlyAllows(t *testing.T) {
bin := buildFork(t, "readonly", readonlyPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if res.exit == 2 {
t.Fatalf("read command was denied (exit=2); stderr=%s", res.stderr)
}
if gjson.Valid(res.stderr) && gjson.Get(res.stderr, "error.subtype").String() == "failed_precondition" {
t.Errorf("read command produced a denial envelope; stderr=%s", res.stderr)
}
}
// identityPlugin registers a Restrict rule scoped to bot identities only.
// im +messages-search declares AuthTypes:["user"] (see
// shortcuts/im/im_messages_search.go), so it has no intersection with the
// rule's bot-only whitelist regardless of which --as value the caller
// passes: platform.Rule.Identities is checked against the command's own
// static supported-identities annotation, not the runtime --as flag.
const identityPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("identity-restrict", "0.1.0").
Restrict(&platform.Rule{
Name: "bot-only",
Allow: []string{"im/**"},
MaxRisk: platform.RiskRead,
Identities: []platform.Identity{platform.IdentityBot},
}).
MustBuild())
}
`
// denylistPlugin registers a Restrict rule that allows the docs/** domain
// but explicitly denies docs/+search (a real read-risk leaf, see
// shortcuts/doc/docs_search.go). Deny has priority over Allow, so the
// command is rejected before MaxRisk is even consulted.
const denylistPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("denylist-restrict", "0.1.0").
Restrict(&platform.Rule{
Name: "deny-search",
Allow: []string{"docs/**"},
Deny: []string{"docs/+search"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// multiRulePlugin registers two scope-exclusive Restrict rules (im-only,
// docs-only). A command outside both domains (e.g. the top-level "schema"
// command, itself read-risk and already proven to hit domain_not_allowed
// under a single Allow:["docs/**","im/**"] rule in TestReadonlyDenial) is
// rejected by both rules, so cmdpolicy's OR-engine collapses the two
// per-rule denials into the aggregate reason_code "no_matching_rule".
const multiRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("multi-rule-restrict", "0.1.0").
Restrict(&platform.Rule{
Name: "im-only",
Allow: []string{"im/**"},
MaxRisk: platform.RiskRead,
}).
Restrict(&platform.Rule{
Name: "docs-only",
Allow: []string{"docs/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// assertReasonCodeEnvelope asserts the VERIFIED envelope shape shared by every
// reason_code across this package -- both policy denials (this file) and
// install-time failures (install_test.go): exit 2, valid JSON on stderr,
// error.type=="validation", error.subtype=="failed_precondition", and
// error.hint containing "reason_code <wantReasonCode>". Both paths render
// through the SAME cmd/platform_guards.go WithHint(...) family, embedding
// reason_code in the hint STRING, not a structured error.detail.reason_code
// field (contradicting internal/platform/error.go:34's comment).
func assertReasonCodeEnvelope(t *testing.T, res result, wantReasonCode string) {
t.Helper()
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "reason_code "+wantReasonCode) {
t.Errorf("hint=%q want to contain reason_code %s", hint, wantReasonCode)
}
}
// TestIdentityMismatchDenial pins reason_code=identity_mismatch: a bot-only
// rule rejects a command whose declared AuthTypes don't include "bot".
func TestIdentityMismatchDenial(t *testing.T) {
bin := buildFork(t, "identity", identityPlugin)
res := run(t, bin, "im", "+messages-search", "--as", "user")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "identity_mismatch")
}
// TestDenylistDenial pins reason_code=command_denylisted: a Deny glob hit
// rejects the command even though it also matches Allow.
func TestDenylistDenial(t *testing.T) {
bin := buildFork(t, "denylist", denylistPlugin)
res := run(t, bin, "docs", "+search")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "command_denylisted")
}
// TestMultiRuleDenial pins reason_code=no_matching_rule: a command rejected
// by every rule in a multi-Restrict() plugin gets the aggregate reason_code,
// not either rule's own per-rule reason_code.
func TestMultiRuleDenial(t *testing.T) {
bin := buildFork(t, "multirule", multiRulePlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "no_matching_rule")
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"os"
"path/filepath"
"testing"
)
// TestMain archives HEAD's committed tree once for the whole package before
// any fork build runs. It lives here (not in harness.go) because `go test`
// only discovers TestMain in a _test.go file — a TestMain defined in a plain
// .go file is silently never invoked.
// NOTE: exactly one TestMain is allowed per package — do not add another in other _test.go files here.
func TestMain(m *testing.M) {
root, err := repoRoot()
if err != nil {
panic("locate repo root: " + err.Error())
}
baseDir, err = os.MkdirTemp("", "plugin-e2e-")
if err != nil {
panic("mkdtemp: " + err.Error())
}
cleanTree = filepath.Join(baseDir, "larkcli-clean")
if err := os.MkdirAll(cleanTree, 0o755); err != nil {
panic("mkdir clean tree: " + err.Error())
}
if err := gitArchive(root, cleanTree); err != nil {
panic("git archive: " + err.Error())
}
code := m.Run()
_ = os.RemoveAll(baseDir)
os.Exit(code)
}
// noopPlugin registers a plugin that installs nothing observable, proving
// the blank-import -> init -> Register -> InstallAll assembly chain links
// and the fork boots.
const noopPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("smoke", "0.0.1").
Observer(platform.After, "noop", platform.All(),
func(_ context.Context, _ platform.Invocation) {}).
FailOpen().
MustBuild())
}
`
func TestSmokeForkBoots(t *testing.T) {
bin := buildFork(t, "smoke", noopPlugin)
res := run(t, bin, "--help")
if res.exit != 0 {
t.Fatalf("--help exit=%d stderr=%s", res.exit, res.stderr)
}
if res.stdout == "" {
t.Fatalf("--help produced empty stdout")
}
}

View File

@@ -0,0 +1,466 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar_e2e proves the sidecar auth-proxy wire protocol end-to-end,
// offline and secret-free: a real fork binary (built with -tags authsidecar,
// exercising the REAL extension/transport/sidecar interceptor) signs a
// request with HMAC-SHA256 and routes it to an in-test sidecar, which
// verifies the signature using the REAL sidecar.Verify / sidecar.CanonicalRequest
// from github.com/larksuite/cli/sidecar, injects a synthetic token, and
// forwards to an in-test mock upstream.
//
// DEVIATION FROM THE ORIGINAL PLAN: the plan called for driving the real
// sidecar/server-demo binary (built with -tags authsidecar_demo) as the
// middle process. That is infeasible for an OFFLINE test, for three
// independent reasons, all verified in source:
//
// 1. sidecar/server-demo/handler.go:171 resolves a REAL token via
// h.cred.ResolveToken(...), which errors out unless the machine has run
// `lark-cli auth login` — there is no way to make it return a token
// without live credentials.
// 2. sidecar/server-demo/main.go builds handler.allowedHosts from
// core.ResolveEndpoints(BrandFeishu/BrandLark) only — real feishu/lark
// hosts. An in-test mock (127.0.0.1:<port>) is never in that allowlist
// and would be rejected with 403 (handler.go step 4).
// 3. sidecar/server-demo/handler.go:184 pins the forward scheme to
// "https://" + targetHost, ignoring the client-supplied scheme. It can
// never be redirected to an http:// mock.
//
// server-demo's verify+inject logic is ALREADY covered by
// `go test -tags authsidecar_demo ./sidecar/server-demo/` (see the
// sidecar-test Makefile target, item 3) — that is unit-level coverage of the
// same code paths this file would otherwise exercise via a real subprocess.
//
// So instead, this test builds its OWN in-test sidecar (an httptest.Server)
// that mirrors server-demo/handler.go's verify+inject steps 0-8 exactly,
// using the real protocol package (sidecar.Verify, sidecar.CanonicalRequest,
// sidecar.BodySHA256, the Header* / Sentinel* / Identity* constants) — the
// same symbols server-demo itself uses. This is the standard shape for this
// kind of test: one real external process (the fork binary, compiled with
// the production interceptor code) plus two in-process httptest.Server
// stand-ins (sidecar, upstream). It proves the real wire protocol end-to-end
// without requiring live credentials, real feishu/lark hosts, or TLS.
//
// Every key/token/app-id here is an obviously-synthetic placeholder; nothing
// in this file can authenticate against anything real.
package sidecar_e2e
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/larksuite/cli/sidecar"
)
// Synthetic, obviously-fake fixtures. None of these are real secrets.
const (
testProxyKey = "test-proxy-key-not-a-real-secret-000000000000"
testAppID = "cli_test_app_not_real"
injectedToken = "fake-injected-token-not-real"
)
// TestSidecarHMACRoundTrip drives the whole wire protocol as three named
// steps so the flow is readable at a glance; each step's mechanics live in a
// dedicated helper below.
func TestSidecarHMACRoundTrip(t *testing.T) {
// Two in-process stand-ins: the mock upstream (for open.feishu.cn) and the
// in-test sidecar (server-demo's verify+inject, via the real protocol pkg).
upstream := startMockUpstream(t)
sc := startInTestSidecar(t, []byte(testProxyKey), upstream.URL)
// One real external process: lark-cli built with -tags authsidecar, run
// fully offline against the in-test sidecar.
bin := buildAuthsidecarFork(t)
runFork(t, bin, sc.URL)
// Assert the three properties of a correct round trip.
assertInterceptorSigned(t, sc) // (a)+(c) fork -> sidecar
assertInjectedTokenReachedUpstream(t, upstream) // (b) sidecar -> upstream
}
// --- request capture -------------------------------------------------------
// capturedRequest snapshots the parts of an *http.Request that matter for
// assertions, taken before the request (and its body reader) is consumed or
// goes out of scope.
type capturedRequest struct {
method string
path string
headers http.Header
body []byte
}
// requestSink stores the request a stub server saw, guarded so the httptest
// handler goroutine and the test goroutine can hand it over safely.
type requestSink struct {
mu sync.Mutex
req *capturedRequest
}
func (s *requestSink) capture(r *http.Request, body []byte) {
snap := capturedRequest{
method: r.Method,
path: r.URL.RequestURI(),
headers: r.Header.Clone(),
body: body,
}
s.mu.Lock()
s.req = &snap
s.mu.Unlock()
}
func (s *requestSink) get() *capturedRequest {
s.mu.Lock()
defer s.mu.Unlock()
return s.req
}
// --- mock upstream (stands in for open.feishu.cn) --------------------------
type mockUpstream struct {
*httptest.Server
sink requestSink
}
func startMockUpstream(t *testing.T) *mockUpstream {
t.Helper()
m := &mockUpstream{}
m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
m.sink.capture(r, body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"code":0,"msg":"success","data":{"document":{"content":"mock content"}}}`))
}))
t.Cleanup(m.Close)
return m
}
// --- in-test sidecar (mirrors server-demo/handler.go verify+inject) --------
type inTestSidecar struct {
*httptest.Server
key []byte
upstreamURL string
sink requestSink
mu sync.Mutex // guards verifyRan/verifyErr
verifyRan bool
verifyErr error
}
func startInTestSidecar(t *testing.T, key []byte, upstreamURL string) *inTestSidecar {
t.Helper()
s := &inTestSidecar{key: key, upstreamURL: upstreamURL}
s.Server = httptest.NewServer(http.HandlerFunc(s.handle))
t.Cleanup(s.Close)
return s
}
// handle is the request flow: capture -> verify (steps 0-4) -> inject+forward.
func (s *inTestSidecar) handle(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
s.sink.capture(r, body)
authHeader, ok := s.verifyProxyRequest(w, r, body)
if !ok {
return
}
s.forwardWithInjectedToken(w, r, body, authHeader)
}
// verifyProxyRequest mirrors server-demo/handler.go steps 0-4: protocol
// version, body SHA256, target validation, and HMAC signature verification.
// It records whether verification ran and its result (for assertions) and
// returns the auth header the client committed to. On any failure it writes
// the HTTP error and returns ok=false.
func (s *inTestSidecar) verifyProxyRequest(w http.ResponseWriter, r *http.Request, body []byte) (authHeader string, ok bool) {
// Step 0: protocol version.
version := r.Header.Get(sidecar.HeaderProxyVersion)
if version != sidecar.ProtocolV1 {
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
return "", false
}
// Step 1-2: timestamp + body SHA256.
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
if claimedSHA == "" || claimedSHA != sidecar.BodySHA256(body) {
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
return "", false
}
// Step 3: target host, identity, auth-header (all covered by the sig).
targetHost, perr := parseTargetHost(r.Header.Get(sidecar.HeaderProxyTarget))
if perr != nil {
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+perr.Error(), http.StatusForbidden)
return "", false
}
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
authHeader = r.Header.Get(sidecar.HeaderProxyAuthHeader)
// Step 4: verify HMAC signature over the canonical request.
err := sidecar.Verify(s.key, sidecar.CanonicalRequest{
Version: version,
Method: r.Method,
Host: targetHost,
PathAndQuery: r.URL.RequestURI(),
BodySHA256: claimedSHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
}, r.Header.Get(sidecar.HeaderProxySignature))
s.mu.Lock()
s.verifyRan = true
s.verifyErr = err
s.mu.Unlock()
if err != nil {
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
return "", false
}
return authHeader, true
}
// forwardWithInjectedToken mirrors server-demo's inject+forward. Unlike
// server-demo (which forwards to "https://"+targetHost), this test forwards to
// the in-test MOCK's URL — proving the sidecar's inject step without needing a
// real upstream or a route to targetHost. It strips any client-supplied auth
// headers first (the sidecar is the sole source of auth material), injects the
// synthetic token into the committed header, and relays the response back.
func (s *inTestSidecar) forwardWithInjectedToken(w http.ResponseWriter, r *http.Request, body []byte, authHeader string) {
freq, err := http.NewRequest(r.Method, s.upstreamURL+r.URL.RequestURI(), bytes.NewReader(body))
if err != nil {
http.Error(w, "failed to build forward request", http.StatusInternalServerError)
return
}
for k, vs := range r.Header {
if isProxyHeader(k) {
continue
}
for _, v := range vs {
freq.Header.Add(k, v)
}
}
freq.Header.Del("Authorization")
freq.Header.Del(sidecar.HeaderMCPUAT)
freq.Header.Del(sidecar.HeaderMCPTAT)
if authHeader == "Authorization" {
freq.Header.Set("Authorization", "Bearer "+injectedToken)
} else {
freq.Header.Set(authHeader, injectedToken)
}
resp, err := http.DefaultClient.Do(freq)
if err != nil {
http.Error(w, "forward failed: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
for k, vs := range resp.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(respBody)
}
// verifyResult reports whether step 4 ran and, if so, its error.
func (s *inTestSidecar) verifyResult() (ran bool, err error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.verifyRan, s.verifyErr
}
// isProxyHeader reports whether name is one of the sidecar wire-protocol
// headers that must not be copied through to the forwarded (mock upstream)
// request. Mirrors sidecar/server-demo/handler.go's isProxyHeader.
func isProxyHeader(name string) bool {
switch http.CanonicalHeaderKey(name) {
case http.CanonicalHeaderKey(sidecar.HeaderProxyVersion),
http.CanonicalHeaderKey(sidecar.HeaderProxyTarget),
http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity),
http.CanonicalHeaderKey(sidecar.HeaderProxySignature),
http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp),
http.CanonicalHeaderKey(sidecar.HeaderBodySHA256),
http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader):
return true
}
return false
}
// parseTargetHost validates X-Lark-Proxy-Target and returns its host.
// Mirrors sidecar/server-demo/handler.go's parseTarget: the header must be
// "https://<host>" with no path, query, fragment, or userinfo. Only the host
// is used, both as HMAC signing input and to record what the fork believed
// its real destination was — the actual forward in this test always goes to
// the in-test mock, never to this host.
func parseTargetHost(target string) (string, error) {
u, err := url.Parse(target)
if err != nil {
return "", fmt.Errorf("parse: %w", err)
}
if u.Scheme != "https" {
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("missing host")
}
if u.User != nil {
return "", fmt.Errorf("userinfo not allowed")
}
if u.Path != "" && u.Path != "/" {
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
}
if u.RawQuery != "" {
return "", fmt.Errorf("query not allowed")
}
if u.Fragment != "" {
return "", fmt.Errorf("fragment not allowed")
}
return u.Host, nil
}
// --- fork build + run ------------------------------------------------------
// buildAuthsidecarFork builds the REAL lark-cli with -tags authsidecar (the
// production interceptor) and returns the binary path.
func buildAuthsidecarFork(t *testing.T) string {
t.Helper()
bin := filepath.Join(t.TempDir(), "forkbin")
build := exec.Command("go", "build", "-tags", "authsidecar", "-o", bin, ".")
build.Dir = repoRoot(t)
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build fork binary: %v\n%s", err, out)
}
return bin
}
// runFork runs the fork against the in-test sidecar, fully offline. The fork's
// exit status is logged but NOT asserted — this test judges wire behavior
// (what reached the sidecar/upstream), not the command's own success.
func runFork(t *testing.T, binPath, sidecarURL string) {
t.Helper()
scURL, err := url.Parse(sidecarURL)
if err != nil {
t.Fatalf("parse sidecar URL: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binPath, "docs", "+fetch", "--doc", "nonexistent", "--as", "user")
cmd.Env = append(os.Environ(),
"LARKSUITE_CLI_AUTH_PROXY=http://"+scURL.Host,
"LARKSUITE_CLI_PROXY_KEY="+testProxyKey,
"LARKSUITE_CLI_APP_ID="+testAppID,
"LARKSUITE_CLI_BRAND=feishu",
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
t.Logf("fork exit error (informational only, not asserted): %v", runErr)
t.Logf("fork stdout: %s", stdout.String())
t.Logf("fork stderr: %s", stderr.String())
}
// repoRoot resolves the lark-cli module root from the test's working
// directory (which `go test` sets to the package dir, tests/sidecar_e2e).
func repoRoot(t *testing.T) string {
t.Helper()
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
t.Fatalf("resolve repo root: %v", err)
}
return strings.TrimSpace(string(out))
}
// --- assertions ------------------------------------------------------------
// assertInterceptorSigned checks the fork -> sidecar hop (assertions a + c):
// the real interceptor ran (all proxy headers present, identity=user), stripped
// every real/sentinel auth header before signing, and produced a signature that
// verified against the shared key.
func assertInterceptorSigned(t *testing.T, sc *inTestSidecar) {
t.Helper()
got := sc.sink.get()
if got == nil {
t.Fatal("sidecar never received a request from the fork — interceptor did not route to AUTH_PROXY")
}
ran, verifyErr := sc.verifyResult()
if !ran {
t.Fatal("sidecar received a request but never reached HMAC verification (rejected earlier — see handler headers)")
}
if verifyErr != nil {
t.Fatalf("HMAC verification failed on the fork's own signed request: %v", verifyErr)
}
t.Logf("fork->sidecar headers: %v", got.headers)
// No real/sentinel auth ever left the fork: the interceptor strips the
// sentinel before signing, so this hop must carry no auth header at all.
if auth := got.headers.Get("Authorization"); auth != "" {
t.Fatalf("fork->sidecar hop leaked an Authorization header (want none, interceptor should have stripped it): %q", auth)
}
if v := got.headers.Get(sidecar.HeaderMCPUAT); v != "" {
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPUAT, v)
}
if v := got.headers.Get(sidecar.HeaderMCPTAT); v != "" {
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPTAT, v)
}
// Proxy headers must be present (proves the interceptor actually ran).
for _, h := range []string{
sidecar.HeaderProxyVersion, sidecar.HeaderProxyTarget, sidecar.HeaderProxyIdentity,
sidecar.HeaderProxySignature, sidecar.HeaderProxyTimestamp, sidecar.HeaderBodySHA256,
sidecar.HeaderProxyAuthHeader,
} {
if got.headers.Get(h) == "" {
t.Fatalf("fork->sidecar hop missing required proxy header %s", h)
}
}
if id := got.headers.Get(sidecar.HeaderProxyIdentity); id != sidecar.IdentityUser {
t.Fatalf("fork->sidecar identity = %q, want %q", id, sidecar.IdentityUser)
}
}
// assertInjectedTokenReachedUpstream checks the sidecar -> upstream hop
// (assertion b): the mock saw exactly the sidecar-injected synthetic token,
// never a sentinel or a real one — proving injection actually happened.
func assertInjectedTokenReachedUpstream(t *testing.T, up *mockUpstream) {
t.Helper()
got := up.sink.get()
if got == nil {
t.Fatal("mock upstream never received a forwarded request — sidecar did not forward after verification")
}
t.Logf("sidecar->mock headers: %v", got.headers)
wantAuth := "Bearer " + injectedToken
gotAuth := got.headers.Get("Authorization")
if gotAuth != wantAuth {
t.Fatalf("mock upstream Authorization = %q, want %q", gotAuth, wantAuth)
}
// Belt-and-suspenders: the value the mock saw must not be either sentinel,
// proving the only token that ever reached "upstream" was the injected one.
if gotAuth == "Bearer "+sidecar.SentinelUAT || gotAuth == "Bearer "+sidecar.SentinelTAT {
t.Fatalf("mock upstream received a sentinel token instead of the injected one: %q", gotAuth)
}
}