mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 19:33:43 +08:00
Compare commits
20 Commits
feat/calen
...
ci/plugin-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84e349eac6 | ||
|
|
2487355dde | ||
|
|
5b235130fb | ||
|
|
85ccfb618a | ||
|
|
eee38bb96f | ||
|
|
15eaaaeccc | ||
|
|
407c484ef2 | ||
|
|
78f8837567 | ||
|
|
1052c9cdbb | ||
|
|
ecded9975e | ||
|
|
d60f71054a | ||
|
|
11d1de8c2e | ||
|
|
10e860ffd2 | ||
|
|
9e5bf906fd | ||
|
|
9ef612bc4a | ||
|
|
71c4c1fda3 | ||
|
|
c2a1ef66ed | ||
|
|
0c25b26d33 | ||
|
|
f495cbb166 | ||
|
|
6f95c5eb22 |
92
.github/workflows/ci.yml
vendored
92
.github/workflows/ci.yml
vendored
@@ -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
|
||||
@@ -263,13 +291,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Run dry-run E2E tests
|
||||
env:
|
||||
@@ -277,7 +311,28 @@ jobs:
|
||||
LARKSUITE_CLI_APP_ID: dry-run
|
||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||
fi
|
||||
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||
fi
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
@@ -292,15 +347,22 @@ jobs:
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Configure bot credentials
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: |
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
@@ -310,16 +372,24 @@ jobs:
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
if [ -z "$packages" ]; then
|
||||
echo "No CLI E2E packages to test after exclusions."
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
exit 1
|
||||
fi
|
||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
@@ -372,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
|
||||
@@ -392,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 }}" \
|
||||
|
||||
12
Makefile
12
Makefile
@@ -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
|
||||
|
||||
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
@@ -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.
|
||||
|
||||
@@ -14,11 +14,13 @@ import (
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
t.Helper()
|
||||
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
||||
}
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
if catalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||
} else {
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
}
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
func strictModeFixtureCatalog() apicatalog.Catalog {
|
||||
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
|
||||
{
|
||||
Name: "fixture",
|
||||
ServicePath: "/open-apis/fixture/v1",
|
||||
Resources: map[string]meta.Resource{
|
||||
"things": {
|
||||
Methods: map[string]meta.Method{
|
||||
"create": {
|
||||
Path: "things",
|
||||
HTTPMethod: "POST",
|
||||
AccessTokens: []meta.Token{meta.TokenTenant},
|
||||
RequestBody: map[string]meta.Field{
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
|
||||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
|
||||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should explicitly skip when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||
! grep -Fq "id: e2e_domains" <<<"$section" ||
|
||||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
|
||||
echo "e2e-live should use resolved live_packages instead of always running the full suite"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
|
||||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
|
||||
echo "e2e-live should pass dynamic domain output through env before shell use"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$dry_run_section"; then
|
||||
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should skip building lark-cli when domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "permissions:" <<<"$section" ||
|
||||
! grep -Fq "contents: read" <<<"$section" ||
|
||||
! grep -Fq "checks: write" <<<"$section"; then
|
||||
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! awk '
|
||||
/^ - name: Configure bot credentials/ { in_step = 1 }
|
||||
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' <<<"$section"; then
|
||||
echo "e2e-live should only configure bot credentials when domain mode is not skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
||||
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
||||
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
|
||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
54
scripts/domain-map.js
Normal file
54
scripts/domain-map.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
|
||||
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
|
||||
|
||||
function normalizeRepoPath(input) {
|
||||
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
|
||||
}
|
||||
|
||||
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
|
||||
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
|
||||
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||
|
||||
function findPathMapping(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
|
||||
}
|
||||
|
||||
function labelDomainsForPath(filePath) {
|
||||
const mapping = findPathMapping(filePath);
|
||||
return mapping ? [...(mapping.labelDomains || [])] : [];
|
||||
}
|
||||
|
||||
function e2eDomainsForPath(filePath) {
|
||||
const mapping = findPathMapping(filePath);
|
||||
return mapping ? [...(mapping.e2eDomains || [])] : [];
|
||||
}
|
||||
|
||||
function matchesFullFallback(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isSkippablePath(filePath) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
const basename = path.posix.basename(normalized);
|
||||
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|
||||
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|
||||
|| (domainMap.skipFilenames || []).includes(basename);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
domainMap,
|
||||
e2eDomainsForPath,
|
||||
findPathMapping,
|
||||
isSkippablePath,
|
||||
labelDomainsForPath,
|
||||
matchesFullFallback,
|
||||
normalizeRepoPath,
|
||||
};
|
||||
71
scripts/domain-map.json
Normal file
71
scripts/domain-map.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"pathMappings": [
|
||||
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
|
||||
|
||||
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
|
||||
],
|
||||
"fullFallbackPrefixes": [
|
||||
"shortcuts/common/",
|
||||
"cmd/",
|
||||
"internal/",
|
||||
"pkg/",
|
||||
"extension/",
|
||||
"registry/",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"Makefile",
|
||||
".github/workflows/",
|
||||
"scripts/"
|
||||
],
|
||||
"skipPrefixes": [
|
||||
"docs/",
|
||||
".changeset/"
|
||||
],
|
||||
"skipSuffixes": [
|
||||
".md",
|
||||
".mdx",
|
||||
".txt",
|
||||
".rst"
|
||||
],
|
||||
"skipFilenames": [
|
||||
"readme.md",
|
||||
"readme.zh.md",
|
||||
"changelog.md",
|
||||
"license",
|
||||
"cla.md"
|
||||
]
|
||||
}
|
||||
224
scripts/e2e_domains.js
Normal file
224
scripts/e2e_domains.js
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const {
|
||||
e2eDomainsForPath,
|
||||
findPathMapping,
|
||||
isSkippablePath,
|
||||
matchesFullFallback,
|
||||
normalizeRepoPath,
|
||||
} = require("./domain-map");
|
||||
|
||||
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
|
||||
process.chdir(ROOT);
|
||||
|
||||
function execLines(command, args) {
|
||||
return execFileSync(command, args, { encoding: "utf8" })
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function modulePath() {
|
||||
return execLines("go", ["list", "-m"])[0];
|
||||
}
|
||||
|
||||
function rootPackage(moduleName) {
|
||||
return `${moduleName}/tests/cli_e2e`;
|
||||
}
|
||||
|
||||
function allLivePackages(moduleName) {
|
||||
return execLines("go", ["list", "./tests/cli_e2e/..."])
|
||||
.filter((pkg) => pkg !== rootPackage(moduleName))
|
||||
.filter((pkg) => !pkg.endsWith("/demo"));
|
||||
}
|
||||
|
||||
function allDryPackages(moduleName) {
|
||||
return allLivePackages(moduleName);
|
||||
}
|
||||
|
||||
const domainExistsCache = new Map();
|
||||
|
||||
function domainExists(domain) {
|
||||
if (domainExistsCache.has(domain)) {
|
||||
return domainExistsCache.get(domain);
|
||||
}
|
||||
let exists = false;
|
||||
try {
|
||||
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
|
||||
exists = true;
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
domainExistsCache.set(domain, exists);
|
||||
return exists;
|
||||
}
|
||||
|
||||
function readChangedFiles() {
|
||||
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
|
||||
if (changedFilesPath) {
|
||||
return fs.readFileSync(changedFilesPath, "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map(normalizeRepoPath)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseRef = process.env.GITHUB_BASE_REF || "main";
|
||||
try {
|
||||
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
|
||||
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function addDomain(domains, domain) {
|
||||
if (domain && domainExists(domain)) {
|
||||
domains.add(domain);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function classifyPath(filePath, domains) {
|
||||
const normalized = normalizeRepoPath(filePath);
|
||||
if (!normalized) return { matched: false };
|
||||
|
||||
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
|
||||
if (e2eMatch) {
|
||||
const domain = e2eMatch[1];
|
||||
if (domain === "demo") return { matched: false };
|
||||
if (domainExists(domain)) {
|
||||
addDomain(domains, domain);
|
||||
return { matched: true };
|
||||
}
|
||||
if (isSkippablePath(normalized)) return { matched: false };
|
||||
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
|
||||
}
|
||||
|
||||
if (normalized.startsWith("tests/cli_e2e/")) {
|
||||
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
|
||||
}
|
||||
|
||||
if (matchesFullFallback(normalized)) {
|
||||
return { fullReason: `shared/runtime path changed: ${normalized}` };
|
||||
}
|
||||
|
||||
const mappedDomains = e2eDomainsForPath(normalized);
|
||||
if (mappedDomains.length > 0) {
|
||||
const missingDomains = [];
|
||||
for (const domain of mappedDomains) {
|
||||
if (!addDomain(domains, domain)) missingDomains.push(domain);
|
||||
}
|
||||
if (missingDomains.length > 0) {
|
||||
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
|
||||
}
|
||||
return { matched: true };
|
||||
}
|
||||
|
||||
if (findPathMapping(normalized)) {
|
||||
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
|
||||
}
|
||||
|
||||
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
|
||||
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
|
||||
}
|
||||
|
||||
if (isSkippablePath(normalized)) return { matched: false };
|
||||
|
||||
return { fullReason: `unclassified path changed: ${normalized}` };
|
||||
}
|
||||
|
||||
function resolveDomains(changedFiles) {
|
||||
const moduleName = modulePath();
|
||||
const rootDryPackage = rootPackage(moduleName);
|
||||
if (changedFiles === null) {
|
||||
return {
|
||||
mode: "full",
|
||||
reason: "non-pull_request run or unavailable diff",
|
||||
domains: ["all"],
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: allDryPackages(moduleName),
|
||||
livePackages: allLivePackages(moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
const domains = new Set();
|
||||
let matchedRelevant = false;
|
||||
let fullReason = "";
|
||||
|
||||
for (const file of changedFiles) {
|
||||
const result = classifyPath(file, domains);
|
||||
if (result.matched) matchedRelevant = true;
|
||||
if (result.fullReason && !fullReason) fullReason = result.fullReason;
|
||||
}
|
||||
|
||||
if (fullReason) {
|
||||
return {
|
||||
mode: "full",
|
||||
reason: fullReason,
|
||||
domains: ["all"],
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: allDryPackages(moduleName),
|
||||
livePackages: allLivePackages(moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRelevant && domains.size > 0) {
|
||||
const sortedDomains = [...domains].sort();
|
||||
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
|
||||
return {
|
||||
mode: "subset",
|
||||
reason: "business domain changes",
|
||||
domains: sortedDomains,
|
||||
dryRootPackage: rootDryPackage,
|
||||
dryPackages: packages,
|
||||
livePackages: packages,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "skip",
|
||||
reason: "docs-only or no live CLI E2E impact",
|
||||
domains: [],
|
||||
dryRootPackage: "",
|
||||
dryPackages: [],
|
||||
livePackages: [],
|
||||
};
|
||||
}
|
||||
|
||||
function emit(resolved) {
|
||||
const values = {
|
||||
mode: resolved.mode,
|
||||
reason: resolved.reason,
|
||||
domains: resolved.domains.join(","),
|
||||
dry_root_package: resolved.dryRootPackage,
|
||||
dry_packages: resolved.dryPackages.join(" "),
|
||||
live_packages: resolved.livePackages.join(" "),
|
||||
};
|
||||
|
||||
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
||||
console.log(lines.join("\n"));
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
emit(resolveDomains(readChangedFiles()));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyPath,
|
||||
readChangedFiles,
|
||||
resolveDomains,
|
||||
};
|
||||
94
scripts/e2e_domains.test.js
Normal file
94
scripts/e2e_domains.test.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const test = require("node:test");
|
||||
|
||||
const scriptPath = path.join(__dirname, "e2e_domains.js");
|
||||
|
||||
function parseOutput(raw) {
|
||||
const result = {};
|
||||
for (const line of raw.trim().split(/\r?\n/)) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
result[line.slice(0, idx)] = line.slice(idx + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function runDomains(files) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
|
||||
const file = path.join(dir, "changed.txt");
|
||||
fs.writeFileSync(file, `${files.join("\n")}\n`);
|
||||
try {
|
||||
return parseOutput(execFileSync(process.execPath, [scriptPath], {
|
||||
cwd: path.join(__dirname, ".."),
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
|
||||
}));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("maps shortcut changes to one business domain package", () => {
|
||||
const output = runDomains(["shortcuts/im/messages/send.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "im");
|
||||
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
|
||||
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||
});
|
||||
|
||||
test("maps doc shortcuts to docs package", () => {
|
||||
const output = runDomains(["shortcuts/doc/update.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "docs");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
|
||||
});
|
||||
|
||||
test("maps direct e2e domain package changes", () => {
|
||||
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "drive");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||
});
|
||||
|
||||
test("falls back to full for shared e2e harness changes", () => {
|
||||
const output = runDomains(["tests/cli_e2e/core.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.equal(output.domains, "all");
|
||||
assert.match(output.reason, /shared CLI E2E harness changed/);
|
||||
});
|
||||
|
||||
test("falls back to full for runtime changes", () => {
|
||||
const output = runDomains(["cmd/root.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.equal(output.domains, "all");
|
||||
assert.match(output.reason, /shared\/runtime path changed/);
|
||||
});
|
||||
|
||||
test("skips docs-only changes", () => {
|
||||
const output = runDomains(["docs/usage.md", "README.md"]);
|
||||
assert.equal(output.mode, "skip");
|
||||
assert.equal(output.domains, "");
|
||||
assert.equal(output.dry_root_package, "");
|
||||
assert.equal(output.live_packages, "");
|
||||
});
|
||||
|
||||
test("uses shared map for skill domain changes", () => {
|
||||
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
|
||||
assert.equal(output.mode, "subset");
|
||||
assert.equal(output.domains, "sheets");
|
||||
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
|
||||
});
|
||||
|
||||
test("falls back to full when a mapped path has no e2e package", () => {
|
||||
const output = runDomains(["shortcuts/whiteboard/export.go"]);
|
||||
assert.equal(output.mode, "full");
|
||||
assert.match(output.reason, /unmapped CLI E2E domain path/);
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { labelDomainsForPath } = require("../domain-map");
|
||||
|
||||
// ============================================================================
|
||||
// Constants & Configuration
|
||||
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
|
||||
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
||||
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
||||
|
||||
// CODEOWNERS-based path to domain label mapping
|
||||
// Maps shortcuts and skills paths to business domain labels
|
||||
const PATH_TO_DOMAIN_MAP = {
|
||||
// shortcuts
|
||||
"shortcuts/im/": "im",
|
||||
"shortcuts/vc/": "vc",
|
||||
"shortcuts/calendar/": "calendar",
|
||||
"shortcuts/doc/": "ccm",
|
||||
"shortcuts/sheets/": "ccm",
|
||||
"shortcuts/drive/": "ccm",
|
||||
"shortcuts/wiki/": "ccm",
|
||||
"shortcuts/base/": "base",
|
||||
"shortcuts/mail/": "mail",
|
||||
"shortcuts/task/": "task",
|
||||
"shortcuts/contact/": "contact",
|
||||
// skills
|
||||
"skills/lark-im/": "im",
|
||||
"skills/lark-vc/": "vc",
|
||||
"skills/lark-doc/": "ccm",
|
||||
"skills/lark-wiki/": "ccm",
|
||||
"skills/lark-base/": "base",
|
||||
"skills/lark-mail/": "mail",
|
||||
"skills/lark-calendar/": "calendar",
|
||||
"skills/lark-task/": "task",
|
||||
"skills/lark-contact/": "contact",
|
||||
};
|
||||
|
||||
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
||||
|
||||
const CLASS_STANDARDS = {
|
||||
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
|
||||
|
||||
// Get business domain label based on CODEOWNERS path mapping
|
||||
function getBusinessDomain(filePath) {
|
||||
const normalized = normalizePath(filePath);
|
||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
return labelDomainsForPath(filePath)[0] || "";
|
||||
}
|
||||
|
||||
async function detectNewShortcutDomain(files) {
|
||||
|
||||
@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||
|
||||
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
cleanup_tmp() {
|
||||
local attempt
|
||||
for attempt in 1 2 3; do
|
||||
rm -rf "$tmp" && return 0
|
||||
sleep 1
|
||||
done
|
||||
rm -rf "$tmp"
|
||||
}
|
||||
|
||||
trap cleanup_tmp EXIT
|
||||
mkdir -p "$tmp"
|
||||
|
||||
git_init() {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
|
||||
eventData := map[string]interface{}{
|
||||
"summary": runtime.Str("summary"),
|
||||
"description": runtime.Str("description"),
|
||||
"start_time": map[string]string{"timestamp": startTs},
|
||||
"end_time": map[string]string{"timestamp": endTs},
|
||||
"attendee_ability": "can_modify_event",
|
||||
@@ -29,15 +30,9 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
|
||||
{"minutes": 5},
|
||||
},
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
eventData["description"] = runtime.Str("description")
|
||||
}
|
||||
if rrule := runtime.Str("rrule"); rrule != "" {
|
||||
eventData["recurrence"] = rrule
|
||||
}
|
||||
if descriptionRich := runtime.Str("description-rich"); descriptionRich != "" {
|
||||
eventData["description_rich"] = descriptionRich
|
||||
}
|
||||
return eventData
|
||||
}
|
||||
|
||||
@@ -104,8 +99,7 @@ var CalendarCreate = common.Shortcut{
|
||||
{Name: "summary", Desc: "event title"},
|
||||
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
|
||||
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
|
||||
{Name: "description", Desc: "event description (plain text)"},
|
||||
{Name: "description-rich", Desc: "rich-text description as Markdown (@file or - for stdin); supports bold/italic/underline/strikethrough, links, ordered/unordered lists, and GFM tables. When set without --description, a plain-text preview is auto-derived so the client renders it immediately", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "description", Desc: "event description"},
|
||||
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||
|
||||
@@ -29,8 +29,7 @@ var CalendarUpdate = common.Shortcut{
|
||||
{Name: "event-id", Desc: "event ID to update", Required: true},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "summary", Desc: "event title"},
|
||||
{Name: "description", Desc: "event description (plain text)"},
|
||||
{Name: "description-rich", Desc: "rich-text description as Markdown (@file or - for stdin); supports bold/italic/underline/strikethrough, links, ordered/unordered lists, and GFM tables. When set without --description, a plain-text preview is auto-derived so the client renders it immediately", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "description", Desc: "event description"},
|
||||
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
|
||||
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
|
||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||
@@ -71,7 +70,7 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
if !hasCalendarUpdateOperation(runtime) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --description-rich, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -115,10 +114,6 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
|
||||
hasFields = true
|
||||
}
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("description-rich") {
|
||||
body["description_rich"] = runtime.Str("description-rich")
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("rrule") {
|
||||
rrule := strings.TrimSpace(runtime.Str("rrule"))
|
||||
if rrule != "" {
|
||||
@@ -358,9 +353,6 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
|
||||
if description, _ := event["description"].(string); description != "" {
|
||||
result["description"] = description
|
||||
}
|
||||
if descriptionRich, _ := event["description_rich"].(string); descriptionRich != "" {
|
||||
result["description_rich"] = descriptionRich
|
||||
}
|
||||
if start := formatCalendarEventTime(event["start_time"]); start != "" {
|
||||
result["start"] = start
|
||||
}
|
||||
|
||||
@@ -51,9 +51,8 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
|
||||
// original message as read after a reply/reply-all/forward operation.
|
||||
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
|
||||
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
|
||||
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
|
||||
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
|
||||
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
|
||||
}
|
||||
|
||||
// hintReadReceiptRequest prints a stderr tip when a message that the caller
|
||||
|
||||
@@ -465,14 +465,19 @@ func TestPrintWatchOutputSchema(t *testing.T) {
|
||||
// TestHintMarkAsRead verifies hint mark as read.
|
||||
func TestHintMarkAsRead(t *testing.T) {
|
||||
rt, _, stderr := newOutputRuntime(t)
|
||||
// Inject ANSI escape + message ID to verify sanitization
|
||||
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
|
||||
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
|
||||
out := stderr.String()
|
||||
if strings.Contains(out, "\x1b[") {
|
||||
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "msg-123") {
|
||||
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
|
||||
if strings.Contains(out, "\nnext") {
|
||||
t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
|
||||
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
|
||||
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
482
shortcuts/mail/mail_message_manage_test.go
Normal file
482
shortcuts/mail/mail_message_manage_test.go
Normal file
@@ -0,0 +1,482 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func messageManageID(suffix string) string {
|
||||
return "msg_abcdefghijklmnop_" + suffix
|
||||
}
|
||||
|
||||
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/messages/" + endpoint,
|
||||
Body: body,
|
||||
}
|
||||
reg.Register(stub)
|
||||
return stub
|
||||
}
|
||||
|
||||
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
|
||||
t.Helper()
|
||||
success, ok := data["success_message_ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
|
||||
}
|
||||
failed, ok := data["failed_message_ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
|
||||
}
|
||||
return success, failed
|
||||
}
|
||||
|
||||
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for %s, got nil", param)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed Problem for %s, got %T", param, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr.Param != param {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
|
||||
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected failed precondition error, got nil")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed Problem, got %T", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
|
||||
}
|
||||
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
|
||||
}
|
||||
|
||||
cases := [][]string{
|
||||
{""},
|
||||
{" id_with_leading_space_12345"},
|
||||
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
|
||||
{"1234567890123456"},
|
||||
{"short"},
|
||||
{"msg_abcdefghijklmnop!"},
|
||||
{"msg_abcdefghijklmnop\t"},
|
||||
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
|
||||
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := normalizeMessageManageIDs(tc)
|
||||
requireMessageManageValidationParam(t, err, "--message-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_Metadata(t *testing.T) {
|
||||
if MailMessageModify.Command != "+message-modify" {
|
||||
t.Fatalf("Command = %q", MailMessageModify.Command)
|
||||
}
|
||||
if MailMessageModify.Risk != "write" {
|
||||
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
|
||||
}
|
||||
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
|
||||
}
|
||||
requiredScopes := map[string]bool{
|
||||
"mail:user_mailbox.message:modify": true,
|
||||
}
|
||||
for _, scope := range MailMessageModify.Scopes {
|
||||
delete(requiredScopes, scope)
|
||||
}
|
||||
if len(requiredScopes) != 0 {
|
||||
t.Errorf("Scopes missing %v", requiredScopes)
|
||||
}
|
||||
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
|
||||
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
|
||||
}
|
||||
flags := map[string]common.Flag{}
|
||||
for _, fl := range MailMessageModify.Flags {
|
||||
flags[fl.Name] = fl
|
||||
}
|
||||
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
|
||||
if _, ok := flags[name]; !ok {
|
||||
t.Fatalf("missing --%s flag", name)
|
||||
}
|
||||
}
|
||||
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
|
||||
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_Metadata(t *testing.T) {
|
||||
if MailMessageTrash.Command != "+message-trash" {
|
||||
t.Fatalf("Command = %q", MailMessageTrash.Command)
|
||||
}
|
||||
if MailMessageTrash.Risk != "high-risk-write" {
|
||||
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
|
||||
}
|
||||
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
|
||||
}
|
||||
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
|
||||
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
token := auth.GetStoredToken("test-app", "ou_testuser")
|
||||
if token == nil {
|
||||
t.Fatal("expected test token")
|
||||
}
|
||||
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
id := messageManageID("1")
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--remove-label-ids", "UNREAD",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
|
||||
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--remove-label-ids", "read_receipt_request",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
|
||||
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
|
||||
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
|
||||
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-label-ids", "unread,customA",
|
||||
"--remove-label-ids", "FLAGGED",
|
||||
"--add-folder", "folderA",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if got := body["add_folder"]; got != "folderA" {
|
||||
t.Errorf("add_folder = %v, want folderA", got)
|
||||
}
|
||||
addLabels := body["add_label_ids"].([]interface{})
|
||||
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
|
||||
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
|
||||
}
|
||||
removeLabels := body["remove_label_ids"].([]interface{})
|
||||
if removeLabels[0] != "FLAGGED" {
|
||||
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 1 || success[0] != id || len(failed) != 0 {
|
||||
t.Errorf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-label-ids", "unread",
|
||||
"--remove-label-ids", "UNREAD",
|
||||
}, f, stdout)
|
||||
requireMessageManageValidationParam(t, err, "--add-label-ids")
|
||||
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
|
||||
t.Fatalf("error = %v, want label intersection validation", err)
|
||||
}
|
||||
|
||||
err = runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-folder", "trash",
|
||||
}, f, stdout)
|
||||
requireMessageManageValidationParam(t, err, "--add-folder")
|
||||
if !strings.Contains(err.Error(), "use +message-trash") {
|
||||
t.Fatalf("error = %v, want TRASH validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id1 + "," + id2 + "," + id1,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
|
||||
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
ids := make([]string, 41)
|
||||
for i := range ids {
|
||||
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
|
||||
}
|
||||
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", strings.Join(ids, ","),
|
||||
"--add-folder", "archive",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
for idx, stub := range []*httpmock.Stub{first, second, third} {
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
|
||||
}
|
||||
messageIDs := body["message_ids"].([]interface{})
|
||||
want := []int{20, 20, 1}[idx]
|
||||
if len(messageIDs) != want {
|
||||
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
|
||||
}
|
||||
if body["add_folder"] != "ARCHIVED" {
|
||||
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
|
||||
}
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 21 || len(failed) != 20 {
|
||||
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id,
|
||||
"--add-folder", "archive",
|
||||
}, f, stdout)
|
||||
requireMessageManageFailedPrecondition(t, err)
|
||||
}
|
||||
|
||||
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||
"+message-modify",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
"--add-label-ids", "customA",
|
||||
"--add-folder", "folderA",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
`/user_mailboxes/me/messages/batch_modify`,
|
||||
`validation_api_plan`,
|
||||
`/user_mailboxes/me/labels/customA`,
|
||||
`/user_mailboxes/me/folders/folderA`,
|
||||
`will_validate`,
|
||||
`batch_size`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q; got %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation error, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
|
||||
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
|
||||
}
|
||||
|
||||
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||
err = runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id1 + "," + id2,
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err with --yes: %v", err)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v", err)
|
||||
}
|
||||
if got := len(body["message_ids"].([]interface{})); got != 2 {
|
||||
t.Fatalf("message_ids len = %d, want 2", got)
|
||||
}
|
||||
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||
if len(success) != 2 || len(failed) != 0 {
|
||||
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
id := messageManageID("1")
|
||||
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||
|
||||
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||
"+message-trash",
|
||||
"--message-ids", id,
|
||||
"--yes",
|
||||
}, f, stdout)
|
||||
requireMessageManageFailedPrecondition(t, err)
|
||||
}
|
||||
|
||||
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
|
||||
id1 := messageManageID("1")
|
||||
id2 := messageManageID("2")
|
||||
cases := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "trash newline in repeated flag",
|
||||
shortcut: MailMessageTrash,
|
||||
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
|
||||
},
|
||||
{
|
||||
name: "trash tab in csv flag",
|
||||
shortcut: MailMessageTrash,
|
||||
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
|
||||
},
|
||||
{
|
||||
name: "modify space in repeated flag",
|
||||
shortcut: MailMessageModify,
|
||||
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
|
||||
},
|
||||
{
|
||||
name: "modify space in csv flag",
|
||||
shortcut: MailMessageModify,
|
||||
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
|
||||
t.Fatalf("error = %v, want whitespace/control validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
141
shortcuts/mail/mail_message_modify.go
Normal file
141
shortcuts/mail/mail_message_modify.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type messageModifyInput struct {
|
||||
MessageIDs []string
|
||||
AddLabelIDs []string
|
||||
RemoveLabelIDs []string
|
||||
AddFolder string
|
||||
CustomLabelIDs []string
|
||||
CustomFolderID string
|
||||
ValidationAPIPlans []validationAPIPlan
|
||||
}
|
||||
|
||||
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
|
||||
// state labels, or a folder move to existing messages in batches of 20.
|
||||
var MailMessageModify = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message-modify",
|
||||
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||
ConditionalScopes: []string{
|
||||
"mail:user_mailbox.folder:read",
|
||||
},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
|
||||
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
|
||||
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
|
||||
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
|
||||
},
|
||||
Validate: validateMessageModify,
|
||||
DryRun: dryRunMessageModify,
|
||||
Execute: executeMessageModify,
|
||||
}
|
||||
|
||||
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := buildMessageModifyInput(rt)
|
||||
return err
|
||||
}
|
||||
|
||||
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
input, _ := buildMessageModifyInput(rt)
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
|
||||
Set("batch_size", mailMessageManageBatchSize).
|
||||
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
|
||||
Set("validation_api_plan", input.ValidationAPIPlans)
|
||||
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
|
||||
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
input, err := buildMessageModifyInput(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
|
||||
emitMessageManageSummary(rt, messageManageSummary{
|
||||
SuccessMessageIDs: input.MessageIDs,
|
||||
FailedMessageIDs: []messageManageFailure{},
|
||||
}, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
|
||||
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||
if err != nil {
|
||||
for _, id := range batch {
|
||||
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||
}
|
||||
emitMessageManageSummary(rt, summary, false)
|
||||
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||
return mailFailedPreconditionError("all message modify batches failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
|
||||
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
|
||||
if err != nil {
|
||||
return messageModifyInput{}, err
|
||||
}
|
||||
customLabels := append(customAddLabels, customRemoveLabels...)
|
||||
customFolderID := ""
|
||||
if customFolder {
|
||||
customFolderID = folder
|
||||
}
|
||||
return messageModifyInput{
|
||||
MessageIDs: messageIDs,
|
||||
AddLabelIDs: addLabels,
|
||||
RemoveLabelIDs: removeLabels,
|
||||
AddFolder: folder,
|
||||
CustomLabelIDs: customLabels,
|
||||
CustomFolderID: customFolderID,
|
||||
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
|
||||
}, nil
|
||||
}
|
||||
75
shortcuts/mail/mail_message_trash.go
Normal file
75
shortcuts/mail/mail_message_trash.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
|
||||
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
|
||||
// runner requires --yes before Execute.
|
||||
var MailMessageTrash = common.Shortcut{
|
||||
Service: "mail",
|
||||
Command: "+message-trash",
|
||||
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
|
||||
},
|
||||
Validate: validateMessageTrash,
|
||||
DryRun: dryRunMessageTrash,
|
||||
Execute: executeMessageTrash,
|
||||
}
|
||||
|
||||
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
return err
|
||||
}
|
||||
|
||||
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
api := common.NewDryRunAPI().
|
||||
Desc("Soft-delete messages sequentially in batches of 20").
|
||||
Set("batch_size", mailMessageManageBatchSize).
|
||||
Set("batches", chunkMessageManageIDs(messageIDs))
|
||||
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
|
||||
Body(map[string]interface{}{"message_ids": batch})
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
mailboxID := resolveMailboxID(rt)
|
||||
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
|
||||
map[string]interface{}{"message_ids": batch})
|
||||
if err != nil {
|
||||
for _, id := range batch {
|
||||
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||
}
|
||||
emitMessageManageSummary(rt, summary, false)
|
||||
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||
return mailFailedPreconditionError("all message trash batches failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
|
||||
RefreshToken: "test-refresh-token",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
|
||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||
}
|
||||
if err := auth.SetStoredToken(token); err != nil {
|
||||
|
||||
283
shortcuts/mail/message_manage_helpers.go
Normal file
283
shortcuts/mail/message_manage_helpers.go
Normal file
@@ -0,0 +1,283 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const mailMessageManageBatchSize = 20
|
||||
|
||||
var messageManageSystemLabels = map[string]string{
|
||||
"UNREAD": "UNREAD",
|
||||
"IMPORTANT": "IMPORTANT",
|
||||
"OTHER": "OTHER",
|
||||
"FLAGGED": "FLAGGED",
|
||||
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
|
||||
}
|
||||
|
||||
var messageManageSystemFolders = map[string]string{
|
||||
"INBOX": "INBOX",
|
||||
"SENT": "SENT",
|
||||
"SPAM": "SPAM",
|
||||
"ARCHIVE": "ARCHIVED",
|
||||
"ARCHIVED": "ARCHIVED",
|
||||
}
|
||||
|
||||
type messageManageSummary struct {
|
||||
SuccessMessageIDs []string `json:"success_message_ids"`
|
||||
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
|
||||
}
|
||||
|
||||
type messageManageFailure struct {
|
||||
MessageID string `json:"message_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type validationAPIPlan struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
WillValidate bool `json:"will_validate"`
|
||||
}
|
||||
|
||||
func normalizeMessageManageIDs(raw []string) ([]string, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||
}
|
||||
parts, err := splitMessageManageIDTokens(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||
}
|
||||
if id != part {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
||||
}
|
||||
if err := validateMessageManageID(id, i); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func splitMessageManageIDTokens(raw []string) ([]string, error) {
|
||||
parts := make([]string, 0, len(raw))
|
||||
for i, token := range raw {
|
||||
for _, r := range token {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
|
||||
}
|
||||
}
|
||||
parts = append(parts, strings.Split(token, ",")...)
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func validateMessageManageID(id string, index int) error {
|
||||
if len(id) < 16 {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
|
||||
}
|
||||
if strings.Trim(id, "0123456789") == "" {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
|
||||
}
|
||||
for _, r := range id {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
|
||||
}
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '+', '/', '=', '_', '-':
|
||||
continue
|
||||
default:
|
||||
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
|
||||
labels := make([]string, 0, len(raw))
|
||||
custom := make([]string, 0, len(raw))
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
for i, part := range raw {
|
||||
id := strings.TrimSpace(part)
|
||||
if id == "" {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
|
||||
}
|
||||
if id != part {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
|
||||
}
|
||||
normalized := id
|
||||
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
|
||||
normalized = system
|
||||
} else {
|
||||
custom = append(custom, id)
|
||||
}
|
||||
if _, ok := seen[normalized]; ok {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
labels = append(labels, normalized)
|
||||
}
|
||||
if len(labels) > 20 {
|
||||
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
|
||||
}
|
||||
return labels, custom, nil
|
||||
}
|
||||
|
||||
func validateLabelIntersection(add, remove []string) error {
|
||||
removeSet := make(map[string]struct{}, len(remove))
|
||||
for _, id := range remove {
|
||||
removeSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range add {
|
||||
if _, ok := removeSet[id]; ok {
|
||||
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeMessageManageFolder(raw string) (string, bool, error) {
|
||||
if raw == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
folder := strings.TrimSpace(raw)
|
||||
if folder == "" {
|
||||
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
|
||||
}
|
||||
if folder != raw {
|
||||
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
|
||||
}
|
||||
if strings.EqualFold(folder, "TRASH") {
|
||||
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
|
||||
}
|
||||
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
|
||||
return system, false, nil
|
||||
}
|
||||
return folder, true, nil
|
||||
}
|
||||
|
||||
func chunkMessageManageIDs(ids []string) [][]string {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
|
||||
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
|
||||
end := start + mailMessageManageBatchSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
chunks = append(chunks, ids[start:end])
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := validateLabelReadScope(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
|
||||
return mailDecorateProblemMessage(err, "label not found: %s", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
if err := validateFolderReadScope(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
|
||||
return mailDecorateProblemMessage(err, "folder not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
|
||||
body := map[string]interface{}{"message_ids": ids}
|
||||
if len(addLabels) > 0 {
|
||||
body["add_label_ids"] = addLabels
|
||||
}
|
||||
if len(removeLabels) > 0 {
|
||||
body["remove_label_ids"] = removeLabels
|
||||
}
|
||||
if addFolder != "" {
|
||||
body["add_folder"] = addFolder
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
|
||||
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
|
||||
seenLabels := map[string]struct{}{}
|
||||
for _, id := range customLabels {
|
||||
if _, ok := seenLabels[id]; ok {
|
||||
continue
|
||||
}
|
||||
seenLabels[id] = struct{}{}
|
||||
plans = append(plans, validationAPIPlan{
|
||||
Method: "GET",
|
||||
Path: mailboxPath(mailboxID, "labels", id),
|
||||
WillValidate: true,
|
||||
})
|
||||
}
|
||||
if customFolder != "" {
|
||||
plans = append(plans, validationAPIPlan{
|
||||
Method: "GET",
|
||||
Path: mailboxPath(mailboxID, "folders", customFolder),
|
||||
WillValidate: true,
|
||||
})
|
||||
}
|
||||
return plans
|
||||
}
|
||||
|
||||
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
|
||||
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
|
||||
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
|
||||
if noAPICalls {
|
||||
fmt.Fprintln(w, "No changes requested; no API calls were made.")
|
||||
}
|
||||
for _, item := range summary.FailedMessageIDs {
|
||||
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,8 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MailMessage,
|
||||
MailMessages,
|
||||
MailMessageModify,
|
||||
MailMessageTrash,
|
||||
MailThread,
|
||||
MailTriage,
|
||||
MailWatch,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
1. `+triage --from spam@x.com` → 列出 N 条结果
|
||||
2. 展示:"将删除 N 封邮件(发件人 spam@x.com,主题:…),确认?"
|
||||
3. 用户确认后 → `*.batch_trash`
|
||||
3. 用户确认后 → `+message-trash --message-ids ... --yes`
|
||||
|
||||
## 身份选择:优先使用 user 身份
|
||||
|
||||
@@ -82,12 +82,13 @@
|
||||
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
|
||||
2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
|
||||
3. **阅读** — `+message` 读单封邮件,`+thread` 读整个会话
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
8. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
9. **已读回执** —
|
||||
4. **整理** — 标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
|
||||
5. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
7. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
9. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
10. **已读回执** —
|
||||
- **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
|
||||
- **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
|
||||
|
||||
@@ -417,7 +418,7 @@ lark-cli mail +message --message-id <id>
|
||||
|
||||
## 原生 API 调用规则
|
||||
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准(API Resources 章节的 resource/method 列表可辅助查阅)。
|
||||
|
||||
### Step 1 — 用 `-h` 确定要调用的 API(必须,不可跳过)
|
||||
|
||||
|
||||
@@ -32,8 +32,7 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
| `--summary <text>` | 否 | 日程标题。注意:标题中不应该出现时间、地点、人物信息 |
|
||||
| `--start <time>` | 是 | 开始时间(ISO 8601,如 `2026-03-12T14:00+08:00`) |
|
||||
| `--end <time>` | 是 | 结束时间(ISO 8601) |
|
||||
| `--description <text>` | 否 | 日程纯文本描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
|
||||
| `--description-rich <markdown>` | 否 | 日程富文本描述,使用 **Markdown** 格式。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、有序/无序列表、以及 GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)。支持 `@文件路径` 或 `-`(stdin)读取。需要富文本格式(如带链接、表格)时使用此参数而非 `--description`。只传 `--description-rich`(不带 `--description`)时,CLI 会自动从富文本派生一段纯文本预览回填 `description`,确保端上创建后即时展示富文本,无需再手动编辑保存 |
|
||||
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
|
||||
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`)。AI 提取时请务必保留对应前缀 |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用主日历) |
|
||||
| `--rrule <rrule>` | 否 | 重复日程的重复性规则,规则设置方式参考rfc5545。示例值:"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
|
||||
|
||||
@@ -43,8 +43,7 @@ lark-cli calendar +update \
|
||||
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID,详见 [重复性日程操作规范](lark-calendar-recurring.md) |
|
||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用 `primary`) |
|
||||
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
|
||||
| `--description <text>` | 否 | 新日程纯文本描述。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空。注意:只传 `--description`(不带 `--description-rich`)会覆盖并清空原有富文本描述 |
|
||||
| `--description-rich <markdown>` | 否 | 新日程富文本描述,使用 **Markdown** 格式(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、有序/无序列表、以及 GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)。支持 `@文件路径` 或 `-`(stdin)读取。仅在显式传入时更新。需保留富文本格式时使用此参数而非 `--description`。 |
|
||||
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
|
||||
| `--start <time>` | 否 | 新开始时间(ISO 8601,如 `2026-03-12T14:00+08:00`)。更新日程时间时必须同时传 `--end` |
|
||||
| `--end <time>` | 否 | 新结束时间(ISO 8601)。更新日程时间时必须同时传 `--start` |
|
||||
| `--rrule <rrule>` | 否 | 新重复规则(RFC5545)。**不要使用 COUNT;如需限制次数,推算后转为 UNTIL** |
|
||||
@@ -53,13 +52,12 @@ lark-cli calendar +update \
|
||||
| `--notify` | 否 | 是否发送更新通知,默认 `true`。可用 `--notify=false` 静默更新 |
|
||||
| `--dry-run` | 否 | 预览 API 调用,不执行 |
|
||||
|
||||
至少需要提供一个动作:`--summary`、`--description`、`--description-rich`、`--start/--end`、`--rrule`、`--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
至少需要提供一个动作:`--summary`、`--description`、`--start/--end`、`--rrule`、`--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
|
||||
## 使用规则
|
||||
|
||||
- `--add-attendee-ids` 是**增量添加**,不是替换最终参与人列表。不要用它表达“只保留这些人”。
|
||||
- 对 `--summary`、`--description`、`--description-rich`,CLI 以“是否显式传入该 flag”判断是否更新,而不是以“值是否为空”判断;如果显式传入空字符串,会把对应字段清空。
|
||||
- 富文本描述与纯文本描述互斥存储:需要保留/写入富文本时用 `--description-rich`;只传 `--description` 会清空原有富文本。
|
||||
- 对 `--summary`、`--description`,CLI 以“是否显式传入该 flag”判断是否更新,而不是以“值是否为空”判断;如果显式传入空字符串,会把对应字段清空。
|
||||
- 只想增删参会人或会议室时,不需要同时传 `--summary`、`--start`、`--end` 等日程字段。
|
||||
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
||||
|
||||
@@ -79,7 +79,7 @@ metadata:
|
||||
|
||||
1. `+triage --from spam@x.com` → 列出 N 条结果
|
||||
2. 展示:"将删除 N 封邮件(发件人 spam@x.com,主题:…),确认?"
|
||||
3. 用户确认后 → `*.batch_trash`
|
||||
3. 用户确认后 → `+message-trash --message-ids ... --yes`
|
||||
|
||||
## 身份选择:优先使用 user 身份
|
||||
|
||||
@@ -96,13 +96,14 @@ metadata:
|
||||
1. **确认身份** — 首次操作邮箱前先调用 `lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"me"}'` 获取当前用户的真实邮箱地址(`primary_email_address`),不要通过系统用户名猜测。后续判断"发件人是否为用户本人"时以此地址为准。
|
||||
2. **浏览** — `+triage` 查看收件箱摘要,获取 `message_id` / `thread_id`
|
||||
3. **阅读** — `+message` 只读单封邮件;已有多个 `message_id` 时用 `+messages` 批量读取,不要循环调用 `+message`;`+thread` 读整个会话
|
||||
4. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
5. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
7. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)已内置 autofix,普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
|
||||
8. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
9. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
10. **已读回执** —
|
||||
4. **整理** — 标签、已读/未读状态和移动文件夹优先用 `+message-modify`;软删除优先用 `+message-trash`
|
||||
5. **回复** — `+reply` / `+reply-all`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
6. **转发** — `+forward`(默认存草稿,加 `--confirm-send` 则立即发送)
|
||||
7. **新邮件** — `+send` 存草稿(默认),加 `--confirm-send` 发送
|
||||
8. **HTML body 预检(可选)** — 复杂 HTML body 提交前可先跑 `+lint-html` 看 lint 会改 / 删什么;写信路径(`+send` / `+draft-create` / `+reply` / `+reply-all` / `+forward` / `+draft-edit` body op)已内置 autofix,普通正文不必先跑。详见 [references/lark-mail-html.md](references/lark-mail-html.md) 中的「写入路径内置 HTML lint」章节
|
||||
9. **确认投递** — 立即发送后用 `send_status` 查询投递状态,定时发送后在预定时间后再查询;取消定时发送用 `cancel_scheduled_send`
|
||||
10. **编辑草稿** — `+draft-edit` 修改已有草稿。正文编辑通过 `--patch-file`:回复/转发草稿用 `set_reply_body` op 保留引用区,普通草稿用 `set_body` op
|
||||
11. **已读回执** —
|
||||
- **请求回执(写信侧)**:`--request-receipt` 仅在**用户显式要求**时添加,**不要从 subject / body 内容推断意图**。
|
||||
- **响应回执(拉信侧)**:拉信看到 `label_ids` 含 `READ_RECEIPT_REQUEST`(或 `-607`)时,**必须先问用户**是否回执(不要自动回执,涉及隐私)。用户同意 → `+send-receipt` 响应;用户不同意但想消掉提示 → `+decline-receipt` 只清本地标签、不发邮件。
|
||||
|
||||
@@ -119,6 +120,8 @@ metadata:
|
||||
- 查看发送邮件后的投递状态:发送成功后查看邮件投递状态;也覆盖发送拦截。ref: [lark-mail-send-status](references/lark-mail-send-status.md)
|
||||
- 使用邮件模板:区分个人模板和静态 HTML 模板,发信类 shortcut 用 `--template-id` 套用模板。ref: [lark-mail-template](references/lark-mail-template.md)
|
||||
- 撤回已发送邮件:撤回邮件并查询异步撤回状态。ref: [lark-mail-recall](references/lark-mail-recall.md)
|
||||
- 修改邮件标签/已读状态/文件夹:优先使用 `+message-modify`。ref: [`+message-modify`](references/lark-mail-message-modify.md)
|
||||
- 软删除邮件:优先使用 `+message-trash`。ref: [`+message-trash`](references/lark-mail-message-trash.md)
|
||||
- 收信规则:创建、验证、删除自动处理收到邮件的规则。ref: [lark-mail-rules](references/lark-mail-rules.md)
|
||||
- 分享邮件到 IM:分享邮件或会话到群聊、个人会话。ref: [lark-mail-share-to-chat](references/lark-mail-share-to-chat.md)
|
||||
- 发送日程邀请邮件:在邮件中嵌入 `text/calendar` 日程邀请。ref: [lark-mail-calendar-invite](references/lark-mail-calendar-invite.md)
|
||||
@@ -192,7 +195,7 @@ lark-cli mail +messages --message-ids <id1>,<id2>,<id3> --html=false
|
||||
|
||||
## 原生 API 调用规则
|
||||
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
|
||||
没有 Shortcut 覆盖的操作才使用原生 API。标签、已读状态、移动文件夹优先使用 `+message-modify`;软删除优先使用 `+message-trash`。调用步骤以本节为准;资源和 method 用 `lark-cli mail -h` / `lark-cli mail <resource> -h` 发现,不在入口保留完整资源表。
|
||||
|
||||
### Step 1 — 用 `-h` 确定要调用的 API(必须,不可跳过)
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
|
||||
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
|
||||
```
|
||||
|
||||
## 编辑转发草稿
|
||||
|
||||
48
skills/lark-mail/references/lark-mail-message-modify.md
Normal file
48
skills/lark-mail/references/lark-mail-message-modify.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# mail +message-modify
|
||||
|
||||
`mail +message-modify` is the preferred shortcut for changing labels, read-state labels, or folder placement on existing messages.
|
||||
|
||||
Use it instead of raw `user_mailbox.messages batch_modify` when the operation targets concrete `message_id` values from `+triage`, `+message`, or `+messages`.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-modify --message-ids <id1>,<id2> --add-label-ids unread
|
||||
lark-cli mail +message-modify --message-ids <id> --remove-label-ids FLAGGED
|
||||
lark-cli mail +message-modify --message-ids <id> --add-folder archive
|
||||
lark-cli mail +message-modify --mailbox shared@example.com --message-ids <id> --add-folder folder_xxx
|
||||
lark-cli mail +message-modify --message-ids <id> --add-label-ids custom_label_id --dry-run
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
|
||||
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
|
||||
| `--add-label-ids` | No | Adds labels. System labels `unread`, `important`, `other`, `flagged` normalize to upper case. |
|
||||
| `--remove-label-ids` | No | Removes labels. Cannot overlap with `--add-label-ids`. |
|
||||
| `--add-folder` | No | Moves to one folder. `inbox`, `sent`, `spam`, `archive`, `archived` normalize to system folder IDs. |
|
||||
|
||||
`TRASH` is intentionally rejected by this shortcut. Use `mail +message-trash --message-ids <id> --yes` for soft deletion.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
|
||||
- Custom label IDs are checked with `labels.get`; custom folder IDs are checked with `folders.get`.
|
||||
- If no label or folder operation is requested, the command succeeds locally, emits all message IDs as `success_message_ids`, and makes no POST request.
|
||||
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
|
||||
- JSON output is intentionally compact:
|
||||
|
||||
```json
|
||||
{
|
||||
"success_message_ids": ["id1"],
|
||||
"failed_message_ids": [
|
||||
{"message_id": "id2", "reason": "api error"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## When Raw API Is Still Appropriate
|
||||
|
||||
Use raw `mail user_mailbox.messages batch_modify` only when you need a request shape that the shortcut intentionally does not expose, or when reproducing backend/API behavior exactly for diagnostics.
|
||||
41
skills/lark-mail/references/lark-mail-message-trash.md
Normal file
41
skills/lark-mail/references/lark-mail-message-trash.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# mail +message-trash
|
||||
|
||||
`mail +message-trash` is the preferred shortcut for soft-deleting existing messages.
|
||||
|
||||
Use it after obtaining real `message_id` values from `+triage`, `+message`, or `+messages`, and after the user has confirmed the deletion preview.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
lark-cli mail +message-trash --message-ids <id1>,<id2> --yes
|
||||
lark-cli mail +message-trash --mailbox shared@example.com --message-ids <id> --yes
|
||||
lark-cli mail +message-trash --message-ids <id1> --message-ids <id2> --dry-run
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `--mailbox` | No | Mailbox that owns the messages. Defaults to `me`. |
|
||||
| `--message-ids` | Yes | `string_array`; supports comma-separated values and repeated flags. |
|
||||
| `--yes` | Yes for execution | Required by the high-risk write confirmation framework. |
|
||||
|
||||
## Behavior
|
||||
|
||||
- Message IDs are locally validated, de-duplicated in first-seen order, and sent in batches of 20.
|
||||
- The shortcut calls `POST /open-apis/mail/v1/user_mailboxes/<mailbox>/messages/batch_trash` sequentially.
|
||||
- Single batch POST failures mark every message in that batch with the same failure reason; later batches still run.
|
||||
- JSON output is intentionally compact:
|
||||
|
||||
```json
|
||||
{
|
||||
"success_message_ids": ["id1"],
|
||||
"failed_message_ids": [
|
||||
{"message_id": "id2", "reason": "api error"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## When Raw API Is Still Appropriate
|
||||
|
||||
Use raw `mail user_mailbox.messages batch_trash` only when reproducing backend/API behavior exactly for diagnostics. For normal soft deletion, prefer this shortcut because it handles validation, batching, compact output, and `--yes` confirmation consistently.
|
||||
@@ -203,7 +203,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
|
||||
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
|
||||
```
|
||||
|
||||
## 相关命令
|
||||
|
||||
@@ -218,7 +218,7 @@ lark-cli mail user_mailbox.drafts cancel_scheduled_send --params '{"user_mailbox
|
||||
**2. 标记已读**(可选)— 询问用户是否需要将原邮件标记为已读。如果用户同意:
|
||||
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages batch_modify --params '{"user_mailbox_id":"me"}' --data '{"message_ids":["<原邮件ID>"],"remove_label_ids":["UNREAD"]}'
|
||||
lark-cli mail +message-modify --message-ids <原邮件ID> --remove-label-ids UNREAD
|
||||
```
|
||||
|
||||
## 编辑回复草稿
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// richTextMarkdown is a Markdown rich-text payload carrying a doc link and
|
||||
// styling. The CLI forwards it verbatim in description_rich; the OpenAPI service
|
||||
// converts Markdown <-> ClientVars.
|
||||
const richTextMarkdown = "见 [设计文档](https://bytedance.feishu.cn/docx/abc) 和 **重点**"
|
||||
|
||||
// TestCalendar_CreateDescriptionRichDryRun verifies that +create forwards the
|
||||
// rich-text payload as-is under the description_rich body field.
|
||||
func TestCalendar_CreateDescriptionRichDryRun(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")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+create",
|
||||
"--calendar-id", "cal_dry",
|
||||
"--summary", "rich dry-run",
|
||||
"--start", "2026-04-25T10:00:00+08:00",
|
||||
"--end", "2026-04-25T11:00:00+08:00",
|
||||
"--description", "plain fallback",
|
||||
"--description-rich", richTextMarkdown,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "plain fallback", gjson.Get(out, "api.0.body.description").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, richTextMarkdown, gjson.Get(out, "api.0.body.description_rich").String(), "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestCalendar_CreateDescriptionRichOnlyDryRun verifies that when only
|
||||
// --description-rich is provided (no --description), +create omits the
|
||||
// description body field entirely. Sending an empty description would suppress
|
||||
// the server's plain-preview backfill and break first-load rendering.
|
||||
func TestCalendar_CreateDescriptionRichOnlyDryRun(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")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+create",
|
||||
"--calendar-id", "cal_dry",
|
||||
"--summary", "rich only dry-run",
|
||||
"--start", "2026-04-25T10:00:00+08:00",
|
||||
"--end", "2026-04-25T11:00:00+08:00",
|
||||
"--description-rich", richTextMarkdown,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, richTextMarkdown, gjson.Get(out, "api.0.body.description_rich").String(), "stdout:\n%s", out)
|
||||
require.False(t, gjson.Get(out, "api.0.body.description").Exists(), "description must be omitted when not set; stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestCalendar_UpdateDescriptionRichDryRun verifies that +update forwards the
|
||||
// rich-text payload as-is under the description_rich body field.
|
||||
func TestCalendar_UpdateDescriptionRichDryRun(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")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"calendar", "+update",
|
||||
"--calendar-id", "cal_dry",
|
||||
"--event-id", "evt_dry",
|
||||
"--description-rich", richTextMarkdown,
|
||||
"--notify=false",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "PATCH", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, richTextMarkdown, gjson.Get(out, "api.0.body.description_rich").String(), "stdout:\n%s", out)
|
||||
}
|
||||
@@ -24,7 +24,17 @@ import (
|
||||
const EnvBinaryPath = "LARK_CLI_BIN"
|
||||
const projectRootMarkerDir = "tests"
|
||||
const cliBinaryName = "lark-cli"
|
||||
const CleanupTimeout = 30 * time.Second
|
||||
|
||||
const (
|
||||
// CleanupTimeout is the outer teardown budget. Keep it above any
|
||||
// per-resource wait so cleanup command retries still have room to run.
|
||||
CleanupTimeout = 60 * time.Second
|
||||
|
||||
defaultRetryAttempts = 4
|
||||
defaultRetryInitialDelay = time.Second
|
||||
defaultRetryMaxDelay = 6 * time.Second
|
||||
defaultRetryBackoffMultiple = 2
|
||||
)
|
||||
|
||||
func SkipWithoutUserToken(t *testing.T) {
|
||||
t.Helper()
|
||||
@@ -102,6 +112,34 @@ type Result struct {
|
||||
RunErr error
|
||||
}
|
||||
|
||||
type cleanupWarningError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *cleanupWarningError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *cleanupWarningError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
// CleanupWarning marks a cleanup verification issue as non-fatal after the
|
||||
// destructive cleanup command itself has already succeeded.
|
||||
func CleanupWarning(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &cleanupWarningError{err: err}
|
||||
}
|
||||
|
||||
// IsCleanupWarning reports whether err should be logged without failing the
|
||||
// parent test.
|
||||
func IsCleanupWarning(err error) bool {
|
||||
var warning *cleanupWarningError
|
||||
return errors.As(err, &warning)
|
||||
}
|
||||
|
||||
// RetryOptions configures retry behavior for flaky external API calls.
|
||||
type RetryOptions struct {
|
||||
Attempts int
|
||||
@@ -111,8 +149,25 @@ type RetryOptions struct {
|
||||
ShouldRetry func(*Result) bool
|
||||
}
|
||||
|
||||
// WaitOptions configures a bounded poll loop for eventually consistent cleanup
|
||||
// or verification checks.
|
||||
type WaitOptions struct {
|
||||
Timeout time.Duration
|
||||
Interval time.Duration
|
||||
TimeoutError func() error
|
||||
}
|
||||
|
||||
// RunCmd executes lark-cli and captures stdout/stderr/exit code.
|
||||
// Service errors that return {"error":{"retryable":true}} are retried with
|
||||
// bounded exponential backoff so individual tests do not need to remember
|
||||
// RunCmdWithRetry for normal transient server contention.
|
||||
func RunCmd(ctx context.Context, req Request) (*Result, error) {
|
||||
return RunCmdWithRetry(ctx, req, RetryOptions{
|
||||
ShouldRetry: ResultHasRetryableError,
|
||||
})
|
||||
}
|
||||
|
||||
func runCmdOnce(ctx context.Context, req Request) (*Result, error) {
|
||||
binaryPath, err := ResolveBinaryPath(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -186,16 +241,16 @@ func buildCommandEnv(req Request) []string {
|
||||
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
|
||||
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
|
||||
if opts.Attempts <= 0 {
|
||||
opts.Attempts = 4
|
||||
opts.Attempts = defaultRetryAttempts
|
||||
}
|
||||
if opts.InitialDelay <= 0 {
|
||||
opts.InitialDelay = 1 * time.Second
|
||||
opts.InitialDelay = defaultRetryInitialDelay
|
||||
}
|
||||
if opts.MaxDelay <= 0 {
|
||||
opts.MaxDelay = 6 * time.Second
|
||||
opts.MaxDelay = defaultRetryMaxDelay
|
||||
}
|
||||
if opts.BackoffMultiple <= 1 {
|
||||
opts.BackoffMultiple = 2
|
||||
opts.BackoffMultiple = defaultRetryBackoffMultiple
|
||||
}
|
||||
if opts.ShouldRetry == nil {
|
||||
opts.ShouldRetry = func(result *Result) bool {
|
||||
@@ -206,7 +261,7 @@ func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Resu
|
||||
delay := opts.InitialDelay
|
||||
var lastResult *Result
|
||||
for attempt := 1; attempt <= opts.Attempts; attempt++ {
|
||||
result, err := RunCmd(ctx, req)
|
||||
result, err := runCmdOnce(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -234,6 +289,63 @@ func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Resu
|
||||
return lastResult, nil
|
||||
}
|
||||
|
||||
// ResultHasRetryableError reports whether lark-cli returned a structured
|
||||
// service error with error.retryable=true in either output stream.
|
||||
func ResultHasRetryableError(result *Result) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
return rawHasRetryableError(result.Stdout) || rawHasRetryableError(result.Stderr)
|
||||
}
|
||||
|
||||
func rawHasRetryableError(raw string) bool {
|
||||
payload := extractJSONPayload(raw)
|
||||
if payload == "" {
|
||||
return false
|
||||
}
|
||||
return gjson.Get(payload, "error.retryable").Bool()
|
||||
}
|
||||
|
||||
// WaitForCondition polls condition until it returns true, an error, the context
|
||||
// is canceled, or the configured timeout expires.
|
||||
func WaitForCondition(ctx context.Context, opts WaitOptions, condition func() (bool, error)) error {
|
||||
if condition == nil {
|
||||
return errors.New("wait condition is nil")
|
||||
}
|
||||
if opts.Timeout <= 0 {
|
||||
opts.Timeout = CleanupTimeout
|
||||
}
|
||||
if opts.Interval <= 0 {
|
||||
opts.Interval = time.Second
|
||||
}
|
||||
|
||||
deadline := time.NewTimer(opts.Timeout)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(opts.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
done, err := condition()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
if opts.TimeoutError != nil {
|
||||
return opts.TimeoutError()
|
||||
}
|
||||
return fmt.Errorf("condition still false after %s", opts.Timeout)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateSuffix returns a high-entropy UTC timestamp suffix suitable for remote test resource names.
|
||||
func GenerateSuffix() string {
|
||||
now := time.Now().UTC()
|
||||
@@ -251,6 +363,10 @@ func ReportCleanupFailure(parentT *testing.T, prefix string, result *Result, err
|
||||
parentT.Helper()
|
||||
|
||||
if err != nil {
|
||||
if IsCleanupWarning(err) {
|
||||
parentT.Logf("%s: %v", prefix, err)
|
||||
return
|
||||
}
|
||||
parentT.Errorf("%s: %v", prefix, err)
|
||||
return
|
||||
}
|
||||
@@ -271,26 +387,11 @@ func isCleanupSuppressedResult(result *Result) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(result.Stdout)
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(result.Stderr)
|
||||
payload := extractJSONPayload(result.Stdout)
|
||||
if payload == "" {
|
||||
payload = extractJSONPayload(result.Stderr)
|
||||
}
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
start := strings.LastIndex(raw, "\n{")
|
||||
if start >= 0 {
|
||||
start++
|
||||
} else {
|
||||
start = strings.Index(raw, "{")
|
||||
}
|
||||
if start < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
payload := raw[start:]
|
||||
if !gjson.Valid(payload) {
|
||||
if payload == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -306,6 +407,32 @@ func isCleanupSuppressedResult(result *Result) bool {
|
||||
return errType == "api_error" && (errCode == 800004135 || strings.Contains(errMessage, " limited"))
|
||||
}
|
||||
|
||||
func extractJSONPayload(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if gjson.Valid(raw) {
|
||||
return raw
|
||||
}
|
||||
|
||||
start := strings.LastIndex(raw, "\n{")
|
||||
if start >= 0 {
|
||||
start++
|
||||
} else {
|
||||
start = strings.Index(raw, "{")
|
||||
}
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
payload := raw[start:]
|
||||
if !gjson.Valid(payload) {
|
||||
return ""
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// ResolveBinaryPath finds the CLI binary path using request, env, then PATH.
|
||||
func ResolveBinaryPath(req Request) (string, error) {
|
||||
if req.BinaryPath != "" {
|
||||
|
||||
@@ -5,10 +5,12 @@ package clie2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -223,6 +225,88 @@ func TestRunCmd(t *testing.T) {
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
|
||||
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
|
||||
})
|
||||
|
||||
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
|
||||
fake := newFakeCLI(t)
|
||||
statePath := filepath.Join(t.TempDir(), "retry-count")
|
||||
result, err := RunCmd(context.Background(), Request{
|
||||
BinaryPath: fake.BinaryPath,
|
||||
Args: []string{"fail-once-retryable", statePath},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
countBytes, err := os.ReadFile(statePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2\n", string(countBytes))
|
||||
})
|
||||
|
||||
t.Run("does not retry non-retryable service errors by default", func(t *testing.T) {
|
||||
fake := newFakeCLI(t)
|
||||
statePath := filepath.Join(t.TempDir(), "retry-count")
|
||||
result, err := RunCmd(context.Background(), Request{
|
||||
BinaryPath: fake.BinaryPath,
|
||||
Args: []string{"always-non-retryable", statePath},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 1)
|
||||
|
||||
countBytes, err := os.ReadFile(statePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1\n", string(countBytes))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunCmdWithRetry(t *testing.T) {
|
||||
t.Run("does not include RunCmd default retry as a nested retry", func(t *testing.T) {
|
||||
fake := newFakeCLI(t)
|
||||
statePath := filepath.Join(t.TempDir(), "retry-count")
|
||||
result, err := RunCmdWithRetry(context.Background(), Request{
|
||||
BinaryPath: fake.BinaryPath,
|
||||
Args: []string{"fail-once-retryable", statePath},
|
||||
}, RetryOptions{
|
||||
Attempts: 1,
|
||||
InitialDelay: time.Millisecond,
|
||||
MaxDelay: time.Millisecond,
|
||||
ShouldRetry: ResultHasRetryableError,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 1)
|
||||
|
||||
countBytes, err := os.ReadFile(statePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1\n", string(countBytes))
|
||||
})
|
||||
}
|
||||
|
||||
func TestWaitForCondition(t *testing.T) {
|
||||
t.Run("polls until condition succeeds", func(t *testing.T) {
|
||||
attempts := 0
|
||||
err := WaitForCondition(context.Background(), WaitOptions{
|
||||
Timeout: 50 * time.Millisecond,
|
||||
Interval: time.Millisecond,
|
||||
}, func() (bool, error) {
|
||||
attempts++
|
||||
return attempts == 2, nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, attempts)
|
||||
})
|
||||
|
||||
t.Run("returns custom timeout error", func(t *testing.T) {
|
||||
wantErr := errors.New("still visible")
|
||||
err := WaitForCondition(context.Background(), WaitOptions{
|
||||
Timeout: time.Millisecond,
|
||||
Interval: time.Millisecond,
|
||||
TimeoutError: func() error { return wantErr },
|
||||
}, func() (bool, error) {
|
||||
return false, nil
|
||||
})
|
||||
|
||||
assert.ErrorIs(t, err, wantErr)
|
||||
})
|
||||
}
|
||||
|
||||
type fakeCLI struct {
|
||||
@@ -260,6 +344,35 @@ if [ "$1" = "emit-stdin" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "fail-once-retryable" ]; then
|
||||
state="$2"
|
||||
count=0
|
||||
if [ -f "$state" ]; then
|
||||
count="$(cat "$state")"
|
||||
fi
|
||||
count=$((count + 1))
|
||||
echo "$count" > "$state"
|
||||
if [ "$count" -eq 1 ]; then
|
||||
echo "Deleting folder fake..." >&2
|
||||
echo '{"ok":false,"error":{"type":"api","code":1061045,"message":"resource contention occurred, please retry.","retryable":true}}' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo '{"ok":true}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "always-non-retryable" ]; then
|
||||
state="$2"
|
||||
count=0
|
||||
if [ -f "$state" ]; then
|
||||
count="$(cat "$state")"
|
||||
fi
|
||||
count=$((count + 1))
|
||||
echo "$count" > "$state"
|
||||
echo '{"ok":false,"error":{"type":"api","code":123,"message":"validation failed","retryable":false}}' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit_code=0
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
|
||||
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
|
||||
| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | |
|
||||
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
|
||||
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
|
||||
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
package docs
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -23,7 +23,7 @@ func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", "doxcnDryRunCompat",
|
||||
"--api-version", "legacy",
|
||||
"--api-version", "v1",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
@@ -14,6 +14,18 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
driveDeleteVisibilityTimeout = 30 * time.Second
|
||||
driveDeleteVisibilityPoll = 3 * time.Second
|
||||
)
|
||||
|
||||
var driveDeleteVisibilityWait = clie2e.WaitOptions{
|
||||
// This wait only covers the post-delete visibility lag after Drive accepts
|
||||
// deletion. The delete command itself is bounded by clie2e.CleanupContext.
|
||||
Timeout: driveDeleteVisibilityTimeout,
|
||||
Interval: driveDeleteVisibilityPoll,
|
||||
}
|
||||
|
||||
// CreateDriveFolder creates a Drive folder, optionally under a parent folder, and
|
||||
// deletes it during parent cleanup.
|
||||
func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string, parentFolderToken string) string {
|
||||
@@ -60,14 +72,18 @@ func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
|
||||
// returned a suppressed not_found or partial API error but the resource still
|
||||
// exists.
|
||||
func DeleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string) (*clie2e.Result, error) {
|
||||
return deleteDriveResourceAndVerify(ctx, token, docType, defaultAs, driveDeleteVisibilityWait)
|
||||
}
|
||||
|
||||
func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string, visibilityWait clie2e.WaitOptions) (*clie2e.Result, error) {
|
||||
if defaultAs == "" {
|
||||
defaultAs = "bot"
|
||||
}
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
deleteResult, deleteErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
|
||||
DefaultAs: defaultAs,
|
||||
}, clie2e.RetryOptions{})
|
||||
})
|
||||
if deleteErr != nil || deleteResult == nil {
|
||||
return deleteResult, deleteErr
|
||||
}
|
||||
@@ -82,35 +98,21 @@ func DeleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs
|
||||
}
|
||||
return deleteResult, fmt.Errorf("drive resource %s/%s still exists after delete failed: exit=%d stdout=%s stderr=%s", docType, token, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
|
||||
}
|
||||
if err := WaitDriveResourceDeleted(ctx, token, docType, defaultAs); err != nil {
|
||||
return deleteResult, err
|
||||
if err := waitDriveResourceDeleted(ctx, token, docType, defaultAs, visibilityWait); err != nil {
|
||||
return deleteResult, clie2e.CleanupWarning(
|
||||
fmt.Errorf("drive resource %s/%s still visible after accepted delete: %w", docType, token, err),
|
||||
)
|
||||
}
|
||||
return deleteResult, nil
|
||||
}
|
||||
|
||||
func WaitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) error {
|
||||
deadline := time.NewTimer(20 * time.Second)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
deleted, err := IsDriveResourceDeleted(ctx, token, docType, defaultAs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
return fmt.Errorf("drive resource %s/%s still exists after delete", docType, token)
|
||||
case <-ticker.C:
|
||||
}
|
||||
func waitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string, opts clie2e.WaitOptions) error {
|
||||
opts.TimeoutError = func() error {
|
||||
return fmt.Errorf("drive resource %s/%s still exists %s after delete", docType, token, opts.Timeout)
|
||||
}
|
||||
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
|
||||
return IsDriveResourceDeleted(ctx, token, docType, defaultAs)
|
||||
})
|
||||
}
|
||||
|
||||
func IsDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) (bool, error) {
|
||||
|
||||
@@ -5,8 +5,13 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -16,3 +21,59 @@ func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
|
||||
require.NotEmpty(t, folderToken)
|
||||
return folderToken
|
||||
}
|
||||
|
||||
func TestDeleteDriveResourceAndVerify(t *testing.T) {
|
||||
t.Run("successful delete with stale meta returns cleanup warning", func(t *testing.T) {
|
||||
fake := mustWriteDriveCleanupFakeCLI(t)
|
||||
t.Setenv(clie2e.EnvBinaryPath, fake)
|
||||
|
||||
result, err := deleteDriveResourceAndVerify(context.Background(), "fld_stale", "folder", "bot", clie2e.WaitOptions{
|
||||
Timeout: 10 * time.Millisecond,
|
||||
Interval: time.Millisecond,
|
||||
})
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, 0, result.ExitCode)
|
||||
require.Error(t, err)
|
||||
assert.True(t, clie2e.IsCleanupWarning(err), "err: %v", err)
|
||||
})
|
||||
|
||||
t.Run("failed delete with existing meta remains fatal", func(t *testing.T) {
|
||||
fake := mustWriteDriveCleanupFakeCLI(t)
|
||||
t.Setenv(clie2e.EnvBinaryPath, fake)
|
||||
t.Setenv("FAKE_DRIVE_DELETE_EXIT", "1")
|
||||
|
||||
result, err := DeleteDriveResourceAndVerify(context.Background(), "fld_existing", "folder", "bot")
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, 1, result.ExitCode)
|
||||
require.Error(t, err)
|
||||
assert.False(t, clie2e.IsCleanupWarning(err), "err: %v", err)
|
||||
assert.Contains(t, err.Error(), "still exists after delete failed")
|
||||
})
|
||||
}
|
||||
|
||||
func mustWriteDriveCleanupFakeCLI(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
script := `#!/bin/sh
|
||||
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
|
||||
if [ "${FAKE_DRIVE_DELETE_EXIT:-0}" != "0" ]; then
|
||||
echo '{"ok":false,"error":{"type":"api","message":"delete failed"}}' >&2
|
||||
exit "$FAKE_DRIVE_DELETE_EXIT"
|
||||
fi
|
||||
echo '{"ok":true}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
|
||||
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected fake CLI args: $*" >&2
|
||||
exit 2
|
||||
`
|
||||
|
||||
binaryPath := filepath.Join(t.TempDir(), "fake-lark-cli")
|
||||
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Mail CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 63 leaf commands
|
||||
- Covered: 14
|
||||
- Coverage: 22.2%
|
||||
- Denominator: 65 leaf commands
|
||||
- Covered: 16
|
||||
- Coverage: 24.6%
|
||||
|
||||
## Summary
|
||||
- TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`.
|
||||
@@ -20,6 +20,8 @@
|
||||
| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape |
|
||||
| ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection |
|
||||
| ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send |
|
||||
| ✓ | mail +message-modify | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageModify_DryRunShowsPlanWithoutValidationGET; shortcuts/mail/mail_message_manage_test.go::TestMessageModify_BatchesAndAggregatesPartialFailure | `--message-ids`; `--add-label-ids`; `--remove-label-ids`; `--add-folder`; `--dry-run` | unit/dry-run coverage locks validation, batching, request shape, and partial failure aggregation; live E2E needs controlled disposable messages/labels/folders |
|
||||
| ✓ | mail +message-trash | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageTrash_RequiresYesAndBatches | `--message-ids`; `--yes`; `--dry-run` | unit coverage locks high-risk confirmation and batch_trash request shape; live E2E needs controlled disposable messages |
|
||||
| ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies |
|
||||
| ✓ | mail +reply | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/reply to received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect reply draft as user | `--message-id`; `--body`; `--plain-text` | creates reply draft from self-generated inbox message and inspects quoted content |
|
||||
| ✕ | mail +reply-all | shortcut | | none | self-send traffic leaves no stable non-self recipient set for deterministic reply-all assertions |
|
||||
|
||||
@@ -5,6 +5,7 @@ package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -203,6 +204,11 @@ type wikiNodeInfo struct {
|
||||
ObjType string
|
||||
}
|
||||
|
||||
const (
|
||||
wikiDeleteVisibilityTimeout = 30 * time.Second
|
||||
wikiDeleteVisibilityPoll = 3 * time.Second
|
||||
)
|
||||
|
||||
// deleteWikiNodeAndVerify removes a wiki node, then polls get_node until the
|
||||
// original node token is gone. Wiki cleanup cannot use drive +delete because
|
||||
// wiki origin nodes need the backing obj_token and parent nodes must delete
|
||||
@@ -333,28 +339,34 @@ func listWikiNodeChildren(ctx context.Context, spaceID, parentNodeToken string)
|
||||
}
|
||||
|
||||
func waitWikiNodeDeleted(ctx context.Context, nodeToken string) error {
|
||||
deadline := time.NewTimer(20 * time.Second)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
var lastTransientErr error
|
||||
|
||||
for {
|
||||
opts := clie2e.WaitOptions{
|
||||
Timeout: wikiDeleteVisibilityTimeout,
|
||||
Interval: wikiDeleteVisibilityPoll,
|
||||
TimeoutError: func() error {
|
||||
if lastTransientErr != nil {
|
||||
return fmt.Errorf("wiki node %s delete verification kept hitting transient errors: %w", nodeToken, lastTransientErr)
|
||||
}
|
||||
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
|
||||
},
|
||||
}
|
||||
|
||||
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
|
||||
deleted, err := isWikiNodeDeleted(ctx, nodeToken)
|
||||
if err != nil {
|
||||
return err
|
||||
if isWikiVerifyTransientError(err) {
|
||||
lastTransientErr = err
|
||||
return false, nil
|
||||
} else {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if deleted {
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline.C:
|
||||
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
|
||||
@@ -375,9 +387,31 @@ func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
|
||||
if isWikiNodeDeletedResult(result) {
|
||||
return true, nil
|
||||
}
|
||||
if isWikiVerifyTransientResult(result) {
|
||||
return false, wikiVerifyTransientError{
|
||||
err: fmt.Errorf("verify wiki node %s after delete hit transient response: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr),
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("verify wiki node %s after delete: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
|
||||
type wikiVerifyTransientError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e wikiVerifyTransientError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e wikiVerifyTransientError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func isWikiVerifyTransientError(err error) bool {
|
||||
var transient wikiVerifyTransientError
|
||||
return err != nil && errors.As(err, &transient)
|
||||
}
|
||||
|
||||
func wikiAPISuccess(stdout string) bool {
|
||||
if ok := gjson.Get(stdout, "ok"); ok.Exists() {
|
||||
return ok.Bool()
|
||||
@@ -404,6 +438,55 @@ func isWikiNodeDeletedResult(result *clie2e.Result) bool {
|
||||
strings.Contains(combined, "not found")
|
||||
}
|
||||
|
||||
func isWikiVerifyTransientResult(result *clie2e.Result) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
payload := result.Stdout
|
||||
if strings.TrimSpace(payload) == "" {
|
||||
payload = result.Stderr
|
||||
}
|
||||
if gjson.Get(payload, "error.type").String() != "internal" ||
|
||||
gjson.Get(payload, "error.subtype").String() != "invalid_response" {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(gjson.Get(payload, "error.message").String())
|
||||
return strings.Contains(message, "http 429") ||
|
||||
strings.Contains(message, "http 500") ||
|
||||
strings.Contains(message, "http 502") ||
|
||||
strings.Contains(message, "http 503") ||
|
||||
strings.Contains(message, "http 504")
|
||||
}
|
||||
|
||||
func TestWikiVerifyTransientResult(t *testing.T) {
|
||||
t.Run("matches invalid response from transient http status", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 5,
|
||||
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: failed to parse TAT response (HTTP 429): invalid character 'r' looking for beginning of value"}}`,
|
||||
}
|
||||
|
||||
require.True(t, isWikiVerifyTransientResult(result))
|
||||
})
|
||||
|
||||
t.Run("does not match unrelated invalid response", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 5,
|
||||
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: malformed body"}}`,
|
||||
}
|
||||
|
||||
require.False(t, isWikiVerifyTransientResult(result))
|
||||
})
|
||||
|
||||
t.Run("does not match api errors", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 1,
|
||||
Stderr: `{"ok":false,"error":{"type":"api","subtype":"conflict","message":"resource contention occurred, please retry","retryable":true}}`,
|
||||
}
|
||||
|
||||
require.False(t, isWikiVerifyTransientResult(result))
|
||||
})
|
||||
}
|
||||
|
||||
func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, nodeToken string, parentNodeTokens ...string) gjson.Result {
|
||||
t.Helper()
|
||||
|
||||
|
||||
265
tests/plugin_e2e/degrade_subsystem_test.go
Normal file
265
tests/plugin_e2e/degrade_subsystem_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
39
tests/plugin_e2e/diagnostics_test.go
Normal file
39
tests/plugin_e2e/diagnostics_test.go
Normal 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
210
tests/plugin_e2e/harness.go
Normal 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)
|
||||
}
|
||||
}
|
||||
339
tests/plugin_e2e/install_test.go
Normal file
339
tests/plugin_e2e/install_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
298
tests/plugin_e2e/observe_wrap_test.go
Normal file
298
tests/plugin_e2e/observe_wrap_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
205
tests/plugin_e2e/restrict_test.go
Normal file
205
tests/plugin_e2e/restrict_test.go
Normal 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")
|
||||
}
|
||||
69
tests/plugin_e2e/smoke_test.go
Normal file
69
tests/plugin_e2e/smoke_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
648
tests/sidecar_e2e/roundtrip_test.go
Normal file
648
tests/sidecar_e2e/roundtrip_test.go
Normal file
@@ -0,0 +1,648 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"
|
||||
|
||||
// testDocToken is the --doc argument runFork passes; the docs +fetch call
|
||||
// becomes POST /open-apis/docs_ai/v1/documents/<testDocToken>/fetch. Sharing
|
||||
// it keeps the request marker below in sync with the command invocation.
|
||||
testDocToken = "nonexistent"
|
||||
|
||||
// docsReqPath is the exact path of the TARGET docs +fetch request among
|
||||
// every request the fork routes through the proxy. `docs +fetch --as user`
|
||||
// resolves a sentinel UAT, and the credential layer then verifies it with
|
||||
// a mandatory /open-apis/authen/v1/user_info probe (see
|
||||
// internal/credential/credential_provider.go enrichUserInfo) — so a second
|
||||
// request also flows through the sidecar. Asserting on whichever arrived
|
||||
// last would let that identity probe masquerade as the docs request; we
|
||||
// select the docs call by its full path (exact match, not a substring — a
|
||||
// wrong API prefix or version must not slip through).
|
||||
docsReqPath = "/open-apis/docs_ai/v1/documents/" + testDocToken + "/fetch"
|
||||
|
||||
// wantProxyTargetHost is the real Feishu open-platform host the interceptor
|
||||
// must name as the proxy target for BRAND=feishu. The request is never
|
||||
// actually forwarded there (the in-test sidecar redirects to the mock); the
|
||||
// header only records where the fork BELIEVED it was going, and it is HMAC
|
||||
// signing input, so it must be exactly the real host.
|
||||
wantProxyTargetHost = "open.feishu.cn"
|
||||
)
|
||||
|
||||
// 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)
|
||||
res := runFork(t, bin, sc.URL)
|
||||
|
||||
// Diagnostic dump (shown only on failure or -v): the full request set, so a
|
||||
// failure makes plain which requests flowed and which one the assertions
|
||||
// targeted, instead of guessing about the last-arriving request.
|
||||
for _, s := range sc.seenAll() {
|
||||
t.Logf("sidecar saw: %s %s target=%q identity=%q verifyRan=%v verifyErr=%v",
|
||||
s.req.method, s.req.path, s.req.headers.Get(sidecar.HeaderProxyTarget),
|
||||
s.req.headers.Get(sidecar.HeaderProxyIdentity), s.verifyRan, s.verifyErr)
|
||||
}
|
||||
for _, r := range upstream.sink.all() {
|
||||
t.Logf("upstream saw: %s %s auth=%q", r.method, r.path, r.headers.Get("Authorization"))
|
||||
}
|
||||
t.Logf("fork exit=%d\nstdout=%s\nstderr=%s", res.exit, res.stdout, res.stderr)
|
||||
|
||||
// Assert the fork's command itself succeeded end-to-end, not just that some
|
||||
// bytes reached the sidecar.
|
||||
assertForkSucceeded(t, res)
|
||||
|
||||
// Assert the three properties of a correct round trip, scoped to the DOCS
|
||||
// request (not an auxiliary identity probe).
|
||||
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 EVERY request a stub server saw, in arrival order,
|
||||
// guarded so the httptest handler goroutine and the test goroutine can hand
|
||||
// them over safely. Capturing all requests (not just the last) is what closes
|
||||
// the false-green window: the fork may route more than one request through the
|
||||
// proxy, and the target docs request is not guaranteed to be the last.
|
||||
type requestSink struct {
|
||||
mu sync.Mutex
|
||||
reqs []*capturedRequest
|
||||
}
|
||||
|
||||
func (s *requestSink) capture(r *http.Request, body []byte) *capturedRequest {
|
||||
snap := &capturedRequest{
|
||||
method: r.Method,
|
||||
path: r.URL.RequestURI(),
|
||||
headers: r.Header.Clone(),
|
||||
body: body,
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.reqs = append(s.reqs, snap)
|
||||
s.mu.Unlock()
|
||||
return snap
|
||||
}
|
||||
|
||||
func (s *requestSink) all() []*capturedRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]*capturedRequest(nil), s.reqs...)
|
||||
}
|
||||
|
||||
// find returns the first captured request whose path equals path, or nil.
|
||||
func (s *requestSink) find(path string) *capturedRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, r := range s.reqs {
|
||||
if r.path == path {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
// Respond per path so each forwarded request parses as success: the
|
||||
// identity probe needs authen/v1/user_info's {data:{open_id,name}} to
|
||||
// resolve cleanly; the docs +fetch is satisfied by the generic code:0
|
||||
// envelope. A single canned body would make the identity probe error.
|
||||
_, _ = w.Write(mockResponseFor(r.URL.Path))
|
||||
}))
|
||||
t.Cleanup(m.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
// mockResponseFor returns a minimal success body matching the API the given
|
||||
// path addresses. Unknown paths get a generic code:0 envelope.
|
||||
func mockResponseFor(path string) []byte {
|
||||
if strings.Contains(path, "/authen/v1/user_info") {
|
||||
return []byte(`{"code":0,"msg":"success","data":{"open_id":"ou_mock","name":"mock user"}}`)
|
||||
}
|
||||
return []byte(`{"code":0,"msg":"success","data":{}}`)
|
||||
}
|
||||
|
||||
// --- in-test sidecar (mirrors server-demo/handler.go verify+inject) --------
|
||||
|
||||
// sidecarSeen is one request the in-test sidecar received, together with the
|
||||
// per-request verification outcome. Tracking this per request (not as a single
|
||||
// last-write-wins field) lets assertions check the verification that belongs
|
||||
// to the DOCS request specifically.
|
||||
type sidecarSeen struct {
|
||||
req *capturedRequest
|
||||
verifyRan bool
|
||||
verifyErr error
|
||||
}
|
||||
|
||||
type inTestSidecar struct {
|
||||
*httptest.Server
|
||||
key []byte
|
||||
upstreamURL string
|
||||
|
||||
mu sync.Mutex // guards seen
|
||||
seen []sidecarSeen
|
||||
}
|
||||
|
||||
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)
|
||||
snap := &capturedRequest{
|
||||
method: r.Method,
|
||||
path: r.URL.RequestURI(),
|
||||
headers: r.Header.Clone(),
|
||||
body: body,
|
||||
}
|
||||
|
||||
authHeader, verifyRan, verifyErr, ok := s.verifyProxyRequest(w, r, body)
|
||||
s.mu.Lock()
|
||||
s.seen = append(s.seen, sidecarSeen{req: snap, verifyRan: verifyRan, verifyErr: verifyErr})
|
||||
s.mu.Unlock()
|
||||
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 returns the auth header the client committed to, whether verification
|
||||
// (step 4) actually ran, and its result. On any pre-step-4 failure it writes
|
||||
// the HTTP error and returns ok=false with verifyRan=false.
|
||||
func (s *inTestSidecar) verifyProxyRequest(w http.ResponseWriter, r *http.Request, body []byte) (authHeader string, verifyRan bool, verifyErr error, 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, nil, 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, nil, 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)
|
||||
// verifyRan=false: step 4 never ran; surface the parse error so the
|
||||
// diagnostic dump shows why this request was rejected early.
|
||||
return "", false, perr, 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))
|
||||
if err != nil {
|
||||
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
|
||||
return "", true, err, false
|
||||
}
|
||||
return authHeader, true, nil, 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)
|
||||
}
|
||||
|
||||
// seenAll returns a copy of every request the sidecar received, in order.
|
||||
func (s *inTestSidecar) seenAll() []sidecarSeen {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]sidecarSeen(nil), s.seen...)
|
||||
}
|
||||
|
||||
// findSeen returns the first received request whose path equals path, or nil.
|
||||
func (s *inTestSidecar) findSeen(path string) *sidecarSeen {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.seen {
|
||||
if s.seen[i].req.path == path {
|
||||
return &s.seen[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// forkResult is the fork subprocess outcome.
|
||||
type forkResult struct {
|
||||
exit int
|
||||
stdout string
|
||||
stderr string
|
||||
}
|
||||
|
||||
// runFork runs the fork against the in-test sidecar, fully offline, and returns
|
||||
// its exit code and captured output. LARKSUITE_CLI_REMOTE_META=off is essential:
|
||||
// without it the fork's startup metadata refresh hits the real
|
||||
// open.feishu.cn/api/tools/open/api_definition (internal/registry/remote.go),
|
||||
// which both breaks the "offline, secret-free" contract and makes the run
|
||||
// depend on live network. With it set, the command still completes and the
|
||||
// docs request still flows through the sidecar, but nothing leaves the machine.
|
||||
func runFork(t *testing.T, binPath, sidecarURL string) forkResult {
|
||||
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", testDocToken, "--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_REMOTE_META=off",
|
||||
"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()
|
||||
exit := 0
|
||||
if runErr != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(runErr, &ee) {
|
||||
exit = ee.ExitCode()
|
||||
} else {
|
||||
t.Fatalf("run fork: %v", runErr)
|
||||
}
|
||||
}
|
||||
return forkResult{exit: exit, stdout: stdout.String(), stderr: 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 ------------------------------------------------------------
|
||||
|
||||
// assertForkSucceeded checks the fork command itself completed the round trip:
|
||||
// exit 0 and an ok:true JSON envelope on stdout. This is what makes the docs
|
||||
// request a genuine success path, not merely bytes that happened to flow.
|
||||
func assertForkSucceeded(t *testing.T, res forkResult) {
|
||||
t.Helper()
|
||||
if res.exit != 0 {
|
||||
t.Fatalf("fork exit=%d want 0; stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
|
||||
}
|
||||
// Parse rather than substring-match: the CLI pretty-prints stdout, so the
|
||||
// envelope reads "ok": true (with a space), and the field's truth — not its
|
||||
// serialized spelling — is what proves the round trip succeeded.
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(res.stdout), &env); err != nil {
|
||||
t.Fatalf("fork stdout is not a JSON envelope: %v; stdout=%s stderr=%s", err, res.stdout, res.stderr)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("fork stdout ok != true (round trip did not succeed); stdout=%s stderr=%s", res.stdout, res.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// assertInterceptorSigned checks the fork -> sidecar hop (assertions a + c) for
|
||||
// the DOCS request specifically: the real interceptor ran (all proxy headers
|
||||
// present, identity=user, method+path+target as expected), 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()
|
||||
seen := sc.findSeen(docsReqPath)
|
||||
if seen == nil {
|
||||
t.Fatalf("sidecar never received the docs request (path %q) — interceptor did not route it to AUTH_PROXY; saw %v",
|
||||
docsReqPath, sidecarPaths(sc.seenAll()))
|
||||
}
|
||||
got := seen.req
|
||||
if !seen.verifyRan {
|
||||
t.Fatal("sidecar received the docs request but never reached HMAC verification (rejected earlier — see handler headers)")
|
||||
}
|
||||
if seen.verifyErr != nil {
|
||||
t.Fatalf("HMAC verification failed on the fork's own signed docs request: %v", seen.verifyErr)
|
||||
}
|
||||
t.Logf("fork->sidecar docs headers: %v", got.headers)
|
||||
|
||||
// Target/method/path: prove we asserted on the real docs call to the real
|
||||
// Feishu open platform, not an auxiliary request.
|
||||
if got.method != http.MethodPost {
|
||||
t.Errorf("docs request method = %q, want POST", got.method)
|
||||
}
|
||||
if targetHost, err := parseTargetHost(got.headers.Get(sidecar.HeaderProxyTarget)); err != nil {
|
||||
t.Errorf("docs request %s invalid: %v", sidecar.HeaderProxyTarget, err)
|
||||
} else if targetHost != wantProxyTargetHost {
|
||||
t.Errorf("docs request proxy target host = %q, want %q", targetHost, wantProxyTargetHost)
|
||||
}
|
||||
|
||||
// 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) for the DOCS request: the mock saw exactly the sidecar-injected
|
||||
// synthetic token, never a sentinel or a real one — proving injection happened.
|
||||
func assertInjectedTokenReachedUpstream(t *testing.T, up *mockUpstream) {
|
||||
t.Helper()
|
||||
got := up.sink.find(docsReqPath)
|
||||
if got == nil {
|
||||
t.Fatalf("mock upstream never received the forwarded docs request (path %q) — sidecar did not forward it after verification; saw %v",
|
||||
docsReqPath, requestPaths(up.sink.all()))
|
||||
}
|
||||
t.Logf("sidecar->mock docs 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)
|
||||
}
|
||||
// The sidecar wire-protocol headers are between fork and sidecar only —
|
||||
// the forward must strip every one of them. Token injection alone passing
|
||||
// would still be a leak if signatures/timestamps/digests reached upstream.
|
||||
for _, h := range []string{
|
||||
sidecar.HeaderProxyVersion, sidecar.HeaderProxyTarget, sidecar.HeaderProxyIdentity,
|
||||
sidecar.HeaderProxySignature, sidecar.HeaderProxyTimestamp, sidecar.HeaderBodySHA256,
|
||||
sidecar.HeaderProxyAuthHeader,
|
||||
} {
|
||||
if v := got.headers.Get(h); v != "" {
|
||||
t.Errorf("proxy protocol header %s leaked to upstream (want stripped): %q", h, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sidecarPaths / requestPaths render captured paths for failure messages.
|
||||
func sidecarPaths(seen []sidecarSeen) []string {
|
||||
paths := make([]string, len(seen))
|
||||
for i, s := range seen {
|
||||
paths[i] = s.req.method + " " + s.req.path
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func requestPaths(reqs []*capturedRequest) []string {
|
||||
paths := make([]string, len(reqs))
|
||||
for i, r := range reqs {
|
||||
paths[i] = r.method + " " + r.path
|
||||
}
|
||||
return paths
|
||||
}
|
||||
Reference in New Issue
Block a user