Compare commits

..

1 Commits

Author SHA1 Message Date
anguohui
4d3c709914 chore: add PPE headers and pin miaoda-cli alpha for testing 2026-07-13 17:00:39 +08:00
893 changed files with 8302 additions and 99592 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,7 +1,4 @@
/go.mod @liangshuo-1
/go.sum @liangshuo-1
/internal/ @liangshuo-1
/shortcuts/common/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1

View File

@@ -1,5 +1,4 @@
name: CI
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
on:
push:
@@ -9,12 +8,6 @@ on:
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
# PR metadata edits can retrigger full CI for the same head. Keep only the
# newest run for a pull request; push and manual runs use a unique run ID.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
actions: read
@@ -54,34 +47,6 @@ 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
@@ -211,11 +176,7 @@ jobs:
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
# tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that
# have dedicated jobs; exclude the whole subtree so none of them runs a
# second time here — and, crucially, so an observe-only suite's failure
# can never block merges through coverage's spot in the results loop.
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
@@ -302,11 +263,6 @@ jobs:
e2e-dry-run:
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
mode: ${{ steps.e2e_domains.outputs.mode }}
reason: ${{ steps.e2e_domains.outputs.reason }}
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -320,23 +276,6 @@ jobs:
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Validate CLI E2E domain outputs
env:
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
case "$E2E_MODE" in
skip)
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
;;
full|subset)
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
;;
*)
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
exit 1
;;
esac
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
@@ -370,22 +309,16 @@ jobs:
fi
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
timeout-minutes: 30
# Live E2E uses one repository-wide execution slot.
concurrency:
group: lark-cli-e2e-live
cancel-in-progress: false
queue: max
permissions:
actions: read
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
LARKSUITE_CLI_BRAND: feishu
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -396,68 +329,31 @@ jobs:
- 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
id: build_cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Prepare shared live E2E tenant token
id: live_e2e_tat
env:
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
run: node scripts/fetch_e2e_tat.js
- name: Run CLI E2E tests
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
# run is rejected below before it can start live E2E.
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
RUN_ID: ${{ github.run_id }}
RUN_NUMBER: ${{ github.run_number }}
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
newer_runs="$(
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
)"
if [ -n "$newer_runs" ]; then
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
exit 1
fi
fi
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
echo "::error::Missing shared live E2E tenant token file"
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"
exit 1
fi
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
rm -f "$E2E_TENANT_AUTH_FILE"
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
./lark-cli whoami --as bot | node -e '
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
const result = JSON.parse(input);
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
});
'; then
echo "::error::Tenant credential preflight failed"
exit 1
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- 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: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
fi
echo "Tenant credential preflight succeeded"
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
@@ -467,7 +363,7 @@ jobs:
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
@@ -520,7 +416,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, plugin-integration, sidecar-integration]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -540,19 +436,10 @@ 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 when not
# needed or on a 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. Graduation to required is tracked in
# https://github.com/larksuite/cli/issues/1894 (criteria: 4
# consecutive weeks with zero false positives).
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \

View File

@@ -9,40 +9,7 @@ permissions:
contents: read
jobs:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
goreleaser:
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -59,79 +26,35 @@ jobs:
with:
python-version: '3.x'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-npm:
needs: build-release
needs: goreleaser
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.14.0'
node-version: '20'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -25,16 +25,19 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
@@ -250,16 +253,19 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");

View File

@@ -10,10 +10,9 @@
## Build & Test
```bash
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
make test # Full: vet + unit + integration
make build # Build (runs fetch_meta first)
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
make test # Full: vet + unit + integration
```
## Notification Opt-Outs
@@ -106,20 +105,6 @@ Signatures that are easy to guess wrong:
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
### Typed data over loose maps
Parse `map[string]interface{}` into a typed struct at the boundary — one projection function per shape — and let everything downstream consume struct fields, not string keys. A typo'd map key compiles fine and fails at runtime, which an agent then debugs blind.
Use distinct types when two values could be swapped silently: see `internal/meta.Token` — a bare string compiles on either side of a string/string signature, a distinct type does not.
Legacy loose-map code exists in older paths. Match its call sites when touching it, but do not copy the pattern into new code.
### Transcribe faithfully — no silent fallbacks
When code echoes input onward (request previews, transformations, proxies), transcribe verbatim. A `default:` branch that coerces unrecognized input into a plausible value ("unknown HTTP verb → GET") makes the output lie, and an agent reasons from the lie.
The same rule applies to flag combinations and internal wiring: if a requested option cannot be honored, return a typed validation error — never silently substitute another behavior and exit 0. Silent guesses (defaulting a missing identity, discarding writes on a nil writer) are bugs even when every current caller happens to avoid them.
### Use `vfs.*` instead of `os.*`
All filesystem access goes through `internal/vfs`. This enables test mocking.
@@ -131,7 +116,6 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
### Tests
- Every behavior change needs a test alongside the change.
- A contract test must fail if the implementation is reverted. If you can undo the code change and the suite stays green, the contract is not pinned — assert the new field/behavior directly, not a happy-path substring.
- `cmdutil.TestFactory(t, config)` for test factories.
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.

View File

@@ -2,319 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.81] - 2026-07-31
### Features
- support visible_rule for form questions (#1891)
- **contact**: add bot search shortcut (#2083)
- add SXSD schema validation to Slides lint (#2103)
- **drive**: add comment-operation shortcuts (#1898)
- **drive**: extend permission shortcuts for Miaoda (#2070)
- **apps**: add cache debug commands (+cache-get/-delete/-clear) (#1896)
- support source file preview artifacts (#2085)
### Bug Fixes
- **contact**: stop bot match segments carrying tags or empty entries (#2115)
- **base**: resolve Base URL block types accurately (#2099)
- **drive**: use title for default download filename (#2089)
- drop stale target version from root upgrade prompt (#2100)
### Documentation
- **calendar**: warn against container-default timezone in time conversion (#2104)
- **calendar**: confirm scope before editing recurring events (#2119)
- **base**: clarify form and file operation routing (#2110)
### Misc
- add protected public domain allowlists (#2111)
## [v1.0.80] - 2026-07-29
### Features
- **drive**: add +member-list shortcut (#1795)
- **drive**: add +permission-get-setting shortcut (#1738)
- propagate invocation metadata (#2097)
### Documentation
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
- **slides**: +create 的参数下沉到 create.md主 skill 只留路由 (#2096)
### Tests
- **e2e**: wait for base role update visibility (#2087)
### Misc
- Feat/detect line text overlap (#2069)
## [v1.0.79] - 2026-07-28
### Features
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
- add okr single create shortcut & skill text opti (#1941)
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
### Bug Fixes
- **base**: improve table shortcut behavior & guidance (#1803)
- issue#1935 & whiteboard shortcut reformat (#1980)
- remove legacy shortcut (#1997)
- **e2e**: inject shared credentials by identity (#1995)
### Documentation
- **skill**: describe html5 block xml usage (#1380)
- clarify fetch metadata and user cites (#1981)
- add topic move collector workflow (#1473)
- update lark doc HTML size limit (#2001)
- **base**: align record write schema guidance (#2000)
### Tests
- **e2e**: declare request identities explicitly (#2004)
### Misc
- harden npm release publishing (#1918)
## [v1.0.74] - 2026-07-21
### Features
- **slides**: add history rollback shortcuts (#1714)
- **base**: support per-record batch updates (#1889)
### Bug Fixes
- preserve slides schema issues
- allow jq examples in quality gate dry-runs
- **im**: warn when flag pagination is truncated (#1906)
- **slides**: warn on text shape overflow
- **slides**: exempt chart roundtrip attributes from lint
- **slides**: detect image text occlusion
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
### Documentation
- clarify drive upload overwrite guidance (#1982)
### Tests
- isolate unit tests from user state (#1883)
### Refactoring
- converge success output through a single Emitter that owns the write (#1899)
## [v1.0.73] - 2026-07-20
### Features
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
### Bug Fixes
- **slides**: detect visual elements outside canvas
- reduce public content credential fixture false positives
- standardize CLI shortcut text in English (#1942)
### Documentation
- **base**: reduce filter and update retry loops (#1879)
- **vc**: default transcript routing to smart notes over minutes (#1961)
- clarify local trigger automation (#1958)
### Tests
- synchronize temporary Git maintenance (#1946)
### Misc
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
- [codex] support bot menu events (#1765)
## [v1.0.72] - 2026-07-17
### Features
- **slides**: lint table out of canvas
- **slides**: report resolved table size mismatches
- **approval**: support approval event consumption (#1924)
### Bug Fixes
- **vc**: don't fail +detail for in-progress meetings (#1930)
- stabilize drive delete E2E terminal-state checks (#1939)
### Documentation
- **slides**: document table dimensions
- document base field default values (#1500)
- **sheets**: use English placeholder in table-get guidance (#1936)
### Tests
- stabilize live e2e auth retries (#1904)
- use tri-state wiki node identity in delete verification (#1931)
- fix drive cover download retries (#1934)
## [v1.0.71] - 2026-07-16
### Features
- add wiki move-to-drive shortcut (#1869)
- **apps**: add role management shortcuts (#1881)
- **drive**: add secure label support and clarify comment location API (#1913)
### Bug Fixes
- **base**: improve dashboard shortcut guidance (#1787)
### Documentation
- **apps**: add platform SQL authoring guide to the db-execute skill (#1912)
### Misc
- add L4 plugin-integration and sidecar-integration CI jobs (#1840)
- **drive**: optimize drive +delete workflow (#1909)
## [v1.0.70] - 2026-07-15
### Features
- add minutes permission application shortcut (#1876)
- **drive**: support apps in list comments (#1877)
- slide style
- edit ppt template
- **slides**: add sxsd validation to slides lint
- **slides**: validate iconpark icon types in slides lint
- **slides**: lint before create
- **apps**: add automation trigger commands for Miaoda (#1886)
### Bug Fixes
- unify dry-run output contract (#1870)
- **skills**: align skill guidance with the typed error contract (#1786)
- **slides**: limit slides screenshot page requests
- **slides**: detect lark slides text overflow overlap
- **vc**: align meeting query scopes by identity (#1850)
### Documentation
- clarify task search relevance filters (#1884)
- surface minutes permission application in skill description (#1890)
- clarify okr progress children (#1861)
- **slides**: prefer slides xml-get shortcut
- **calendar**: document setting meeting owner via full API (#1903)
### Refactoring
- **slides**: streamline create workflow and validate SML namespaces
### Misc
- **slides**: address PR review feedback
## [v1.0.69] - 2026-07-13
### Features
- support docs fetch selection anchors (#1815)
- **apps**: support modern_html app type with TOS publish path and app type querying
- **im**: show bot sender display names when reading messages (#1829)
- add drive list comments shortcut (#1845)
- support wiki sources in drive export (#1802)
- add application domain with slash command management shortcuts (#1806)
- validate IM idempotency key length (#1797)
- surface reply context and mentions in im.message.receive_v1 (#1798)
### Bug Fixes
- route brand-sensitive endpoints through the resolver (#1836)
### Documentation
- document OKR block XML guidance (#1648)
- refine doubao whiteboard workflow routing (#1841)
- clarify Mindnote token handling (#1827)
### Tests
- isolate semantic waiver fixtures from wall clock
### Misc
- Merge lark sheets development branch (#1833)
## [v1.0.68] - 2026-07-09
### Features
@@ -1751,18 +1438,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.81]: https://github.com/larksuite/cli/releases/tag/v1.0.81
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66

View File

@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
all: test
@@ -51,27 +51,19 @@ 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/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.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
$(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
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/...
live-skills-test: fetch_meta
LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
go test -v -count=1 ./cmd/update \
-run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
# examples-build keeps the shipped plugin-SDK examples compilable. If this
# breaks, the plugin author guide's "go build ./..." path is broken.
examples-build:
go build ./extension/platform/examples/audit-observer
go build ./extension/platform/examples/readonly-policy
# ./tests/... includes tests/plugin_e2e, which builds ~20 customer-fork
# binaries (~1 min warm; a cold module cache also downloads via GOPROXY).
# Deliberate: local `make test` exercises the L4 plugin contract by default.
integration-test: build
go test -v -count=1 ./tests/...
@@ -113,14 +105,6 @@ uninstall:
clean:
rm -f $(BINARY)
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
# default CI matrix never sees (they carry //go:build tags).
sidecar-test:
go build -tags authsidecar -o /dev/null .
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.

View File

@@ -285,29 +285,6 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History

View File

@@ -286,29 +286,6 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

View File

@@ -23,41 +23,6 @@ lark-cli contact +search-user --query "alice" --as user
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +search-bot
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
### Skills
- lark-contact/references/lark-contact-search-bot.md
### Avoid when
- Looking for a person rather than a bot → use [[+search-user]]
- Running as a bot — this shortcut is user-only
### Tips
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
### Examples
**Find bots by keyword**
```bash
lark-cli contact +search-bot --query "会议助手" --as user
```
**Search inside one chat**
```bash
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
```
**Find bots you've chatted with**
```bash
lark-cli contact +search-bot --query "助手" --has-chatted --as user
```
**Search several bot keywords in one call**
```bash
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.

View File

@@ -130,13 +130,6 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
stdin := opts.Factory.IOStreams.In
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
if opts.Method == "" {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"HTTP method must not be empty").
WithHint("pass the verb as the first argument, e.g. lark-cli api GET /open-apis/...").
WithParam("<method>")
}
// Validate --file mutual exclusions first.
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
return client.RawApiRequest{}, nil, err
@@ -250,9 +243,9 @@ func apiRun(opts *APIOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
}
return apiDryRun(f, request, config, opts)
return apiDryRun(f, request, config, opts.Format)
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
@@ -304,19 +297,8 @@ func apiRun(opts *APIOptions) error {
return nil
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
}
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
@@ -344,18 +326,20 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)

View File

@@ -1,396 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
type apiFailOnWriteWriter struct {
buf bytes.Buffer
writes int
failAt int
err error
}
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
w.writes++
if w.writes == w.failAt {
return 0, w.err
}
return w.buf.Write(p)
}
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
previousNotice := output.PendingNotice
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
ac.ErrOut = io.Discard
return ac, out, errOut, reg
}
func apiPaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: core.AsBot,
}
}
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal expected JSON: %v", err)
}
wantBytes = append(wantBytes, '\n')
if !bytes.Equal(got, wantBytes) {
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
}
}
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
calls := 0
wantTokens := []string{"", "next-1", "next-2"}
for i, wantToken := range wantTokens {
page := i + 1
hasMore := page < len(wantTokens)
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = wantTokens[page]
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(req *http.Request) {
calls++
if got := req.URL.Query().Get("page_token"); got != wantToken {
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
map[string]interface{}{"id": "3"},
},
"has_more": false,
},
})
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Carol\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
},
"has_more": true,
"page_token": "next-1",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
},
"has_more": false,
},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
sentinel := errors.New("page write failed")
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
calls := 0
for page := 1; page <= 2; page++ {
hasMore := true
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": page}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = fmt.Sprintf("next-%d", page)
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(*http.Request) {
calls++
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
if !errors.Is(err, sentinel) {
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
}
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
}
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
businessResponse := map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{"detail": "business failed"},
}
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "default_json", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: businessResponse,
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "stream_pages", format: output.FormatNDJSON},
{name: "default_paginate_all", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want transport error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{},
},
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}

View File

@@ -69,7 +69,7 @@ func TestApiCmd_FlagParsing(t *testing.T) {
}
func TestApiCmd_DryRun(t *testing.T) {
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
@@ -79,42 +79,12 @@ func TestApiCmd_DryRun(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
output := stdout.String()
if !strings.Contains(output, "Dry Run") {
t.Error("expected dry run output")
}
if got["ok"] != true || got["identity"] != "bot" || got["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", got)
}
data, ok := got["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v, want object", got["data"])
}
api, ok := data["api"].([]interface{})
if !ok || len(api) != 1 {
t.Fatalf("api = %#v, want one call", data["api"])
}
call, ok := api[0].(map[string]interface{})
if !ok || call["url"] != "/open-apis/test" {
t.Fatalf("api[0] = %#v", api[0])
}
if strings.Contains(stdout.String(), "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", stdout.String())
}
}
func TestApiCmd_DryRunWithJq(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run", "--jq", ".data.api[0].url"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != "/open-apis/test" {
t.Fatalf("jq output = %q, want /open-apis/test", got)
if !strings.Contains(output, "/open-apis/test") {
t.Error("expected path in dry run output")
}
}
@@ -182,22 +152,6 @@ func TestApiCmd_MissingArgs(t *testing.T) {
}
}
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected validation error for empty HTTP method")
}
if !strings.Contains(err.Error(), "method") {
t.Fatalf("error should name the method argument, got: %v", err)
}
}
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -352,9 +306,6 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
}
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
})
@@ -374,33 +325,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
if !strings.Contains(stderr.String(), "binary response detected") {
t.Error("expected binary response hint in stderr")
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
}
savedPath, _ := got["saved_path"].(string)
if savedPath == "" {
t.Fatalf("saved_path missing from output: %#v", got)
}
// The file must land inside the temporary cwd — this pins the isolation
// contract: rolling back TestChdir would leave download.bin in the repo.
wantDir, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
if err != nil {
t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
}
if gotDir != wantDir {
t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
}
content, err := os.ReadFile(savedPath)
if err != nil {
t.Fatalf("read saved file: %v", err)
}
if string(content) != "fake-binary-content" {
t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
if !strings.Contains(stdout.String(), "saved_path") {
t.Error("expected saved_path in output")
}
}
@@ -1074,23 +1000,11 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
}
if env["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
body := call["body"].(map[string]interface{})
file := body["file"].(map[string]interface{})
if file["field"] != "image" || file["path"] != tmpFile {
t.Fatalf("unexpected file dry-run body: %#v", body)
}
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", out)
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
}
}

View File

@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates auth command tests from the host machine: config, logs
// and the registry cache are redirected to a temp dir, then the registry is
// seeded from the tracked fixture and initialized eagerly. Domain-completion
// tests read the registry, so without seeding a clean checkout would either
// fail or trigger a remote metadata fetch.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
if err != nil {
println("cmd/auth test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
println("cmd/auth test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
println("cmd/auth test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -31,7 +31,6 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))

View File

@@ -1,80 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -1,130 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

@@ -17,8 +17,6 @@ import (
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
@@ -38,8 +36,6 @@ func TestRunList_TextOutput(t *testing.T) {
out := stdout.String()
for _, want := range []string{
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
@@ -94,8 +90,6 @@ func TestRunList_JSONOutput(t *testing.T) {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {

View File

@@ -19,29 +19,6 @@ import (
_ "github.com/larksuite/cli/events"
)
type approvalSchemaJSONPayload struct {
JQRootPath string `json:"jq_root_path"`
AuthTypes []string `json:"auth_types"`
Scopes []string `json:"scopes"`
Params []approvalSchemaJSONParam `json:"params"`
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
}
type approvalSchemaJSONParam struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
SubscriptionKey bool `json:"subscription_key"`
}
type approvalSchemaJSONResolvedSchema struct {
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
}
type approvalSchemaJSONProperty struct {
Format string `json:"format"`
}
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
@@ -119,40 +96,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "im.message.receive_v1", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
resolved := payload["resolved_output_schema"].(map[string]interface{})
props := resolved["properties"].(map[string]interface{})
for _, field := range []string{
"root_id",
"thread_id",
"reply_to",
"sender_type",
"mentions",
} {
if _, ok := props[field]; !ok {
t.Errorf("receive schema missing field %q", field)
}
}
msgDesc := props["message_id"].(map[string]interface{})["description"].(string)
if !strings.Contains(msgDesc, "Recommended idempotency key") {
t.Errorf("message_id description should guide deduplication, got %q", msgDesc)
}
eventDesc := props["event_id"].(map[string]interface{})["description"].(string)
if strings.Contains(eventDesc, "safe for deduplication") {
t.Errorf("event_id description should not recommend deduplication, got %q", eventDesc)
}
}
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
@@ -181,60 +124,6 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
}
}
func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
tests := []struct {
key string
scope string
}{
{"approval.instance.status_changed_v4", "approval:instance:read"},
{"approval.task.status_changed_v4", "approval:task:read"},
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, tc.key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload approvalSchemaJSONPayload
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload.JQRootPath != "." {
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
}
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
t.Errorf("auth_types = %#v, want user", got)
}
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
t.Errorf("scopes = %#v, want %s", got, tc.scope)
}
if len(payload.Params) != 1 {
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
}
param := payload.Params[0]
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
}
props := payload.ResolvedOutputSchema.Properties
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
if _, ok := props[field]; !ok {
t.Errorf("approval schema missing flat field %q: %+v", field, props)
}
}
if _, ok := props["event"]; ok {
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
}
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("operate_time format = %v, want timestamp_ms", got)
}
})
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",

View File

@@ -371,11 +371,10 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run",
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
if code != output.ExitValidation {

View File

@@ -65,17 +65,7 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if info == nil {
return
}
// Deliberately no target version here: info.Latest comes from the on-disk
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
// failed refresh leaves the old value in place), so it can name a version
// that is no longer the one npm would install. The version actually
// installed is resolved live by the update subcommand, which prints
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
// that is where the user sees the real target. Keep going through the
// update subcommand rather than calling RunNpmInstall directly, otherwise
// that line disappears and the user approves a global install without ever
// being told what gets installed.
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
if !readYes(ios.In) {
return
}

View File

@@ -128,17 +128,6 @@ func TestOfferRootUpgrade(t *testing.T) {
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
// The prompt must not name a target version: info.Latest comes from
// the on-disk cache and can be stale, while the version actually
// installed is resolved live by the update subcommand.
if tc.wantPrompt {
if strings.Contains(errBuf.String(), tc.latest) {
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
}
if !strings.Contains(errBuf.String(), build.Version) {
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
}
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}

View File

@@ -403,9 +403,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
}
return serviceDryRun(f, request, config, opts)
return serviceDryRun(f, request, config, opts.Format)
}
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
@@ -667,19 +667,8 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return request, nil, nil
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
}
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
@@ -707,18 +696,20 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return err

View File

@@ -1,400 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
type serviceFailOnWriteWriter struct {
buf bytes.Buffer
writes int
failAt int
err error
}
func (w *serviceFailOnWriteWriter) Write(p []byte) (int, error) {
w.writes++
if w.writes == w.failAt {
return 0, w.err
}
return w.buf.Write(p)
}
func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
previousNotice := output.PendingNotice
output.PendingNotice = nil
t.Cleanup(func() { output.PendingNotice = previousNotice })
config := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
f, out, errOut, reg := cmdutil.TestFactory(t, config)
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
}
ac.ErrOut = io.Discard
return ac, out, errOut, reg
}
func servicePaginateRequest() client.RawApiRequest {
return client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test/v1/items",
As: core.AsBot,
}
}
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
if err != nil {
t.Fatalf("marshal expected JSON: %v", err)
}
wantBytes = append(wantBytes, '\n')
if !bytes.Equal(got, wantBytes) {
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
}
}
func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
calls := 0
wantTokens := []string{"", "next-1", "next-2"}
for i, wantToken := range wantTokens {
page := i + 1
hasMore := page < len(wantTokens)
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = wantTokens[page]
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(req *http.Request) {
calls++
if got := req.URL.Query().Get("page_token"); got != wantToken {
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
}
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
map[string]interface{}{"id": "3"},
},
"has_more": false,
},
})
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}
func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Carol\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
},
"has_more": true,
"page_token": "next-1",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
},
"has_more": false,
},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
ac, _, errOut, reg := newServicePaginateTestHarness(t)
sentinel := errors.New("page write failed")
out := &serviceFailOnWriteWriter{failAt: 2, err: sentinel}
calls := 0
for page := 1; page <= 2; page++ {
hasMore := true
data := map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": page}},
"has_more": hasMore,
}
if hasMore {
data["page_token"] = fmt.Sprintf("next-%d", page)
}
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
OnMatch: func(*http.Request) {
calls++
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
if !errors.Is(err, sentinel) {
t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
}
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
}
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
}
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
}
}
func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
businessResponse := map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{"detail": "business failed"},
}
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "default_json", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: businessResponse,
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
assertServicePaginateJSONBytes(t, out.Bytes(), businessResponse)
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
tests := []struct {
name string
format output.Format
jqExpr string
}{
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
{name: "stream_pages", format: output.FormatNDJSON},
{name: "default_paginate_all", format: output.FormatJSON},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ac, out, errOut, _ := newServicePaginateTestHarness(t)
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want transport error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
})
}
}
func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 123456,
"msg": "fixture business error",
"data": map[string]interface{}{},
},
})
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
}
if got := out.String(); got != "" {
t.Fatalf("stdout bytes = %q, want empty", got)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
}

View File

@@ -224,39 +224,13 @@ func TestServiceMethod_DryRun_PathParam(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", got)
}
data := got["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
if call["url"] != tt.wantInURL {
t.Errorf("url = %q, want %q\nstdout:\n%s", call["url"], tt.wantInURL, stdout.String())
if !strings.Contains(stdout.String(), tt.wantInURL) {
t.Errorf("expected URL containing %q, got:\n%s", tt.wantInURL, stdout.String())
}
})
}
}
func TestServiceMethod_DryRunWithJq(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "get", "files", nil)
cmd.SetArgs([]string{
"--params", `{"file_token":"boxcn123abc"}`,
"--dry-run",
"--jq", ".data.api[0].url",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := strings.TrimSpace(stdout.String()), "/open-apis/drive/v1/files/boxcn123abc/copy"; got != want {
t.Fatalf("jq output = %q, want %q", got, want)
}
}
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
tests := []struct {
name string
@@ -344,12 +318,8 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
if err != nil {
t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
}
if got["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", got["dry_run"])
if !strings.Contains(stdout.String(), "Dry Run") {
t.Error("expected dry-run output")
}
}
@@ -1111,23 +1081,11 @@ func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
if !strings.Contains(out, "image") {
t.Errorf("expected dry-run output to mention file field, got: %s", out)
}
if env["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
body := call["body"].(map[string]interface{})
file := body["file"].(map[string]interface{})
if file["field"] != "image" || file["path"] != tmpFile {
t.Fatalf("unexpected file dry-run body: %#v", body)
}
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("stdout should not contain dry-run banner: %s", out)
if !strings.Contains(out, "Dry Run") {
t.Errorf("expected dry-run header, got: %s", out)
}
}

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"os"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates service command tests from the host machine: config (and
// the registry cache under it) is redirected to a temp dir, then the registry
// is seeded from the tracked fixture and initialized eagerly. Tests pass on a
// clean checkout with no network, no `make fetch_meta`, and no user cache.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-cmd-service-test-*")
if err != nil {
println("cmd/service test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
println("cmd/service test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd/service test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -5,7 +5,6 @@ package cmd
import (
"context"
"flag"
"fmt"
"os"
"os/exec"
@@ -13,34 +12,11 @@ import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
)
const startupBrandHelperEnv = "GO_TEST_STARTUP_BRAND_HELPER"
var _ = flag.String("startup-brand-helper", "", "internal startup brand test helper nonce")
func isStartupBrandHelper() bool {
return startupBrandHelperEnabled(os.Getenv(startupBrandHelperEnv), startupBrandHelperNonce(os.Args))
}
func startupBrandHelperEnabled(envNonce, argNonce string) bool {
return envNonce != "" && envNonce == argNonce
}
func startupBrandHelperNonce(args []string) string {
const prefix = "-startup-brand-helper="
for _, arg := range args {
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix)
}
}
return ""
}
func TestResolveStartupBrand_Precedence(t *testing.T) {
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
@@ -78,7 +54,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
// sync.Once, so the brand must be injected before the first catalog access.
// It runs in a subprocess because the registry is process-global.
func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
if isStartupBrandHelper() {
if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
// Helper: replicate Execute()'s build wiring with a lark config.
buildInternal(
context.Background(), cmdutil.InvocationContext{},
@@ -95,11 +71,9 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Fatal(err)
}
nonce := uuid.NewString()
t.Setenv(startupBrandHelperEnv, nonce)
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
cmd.Args = append(cmd.Args, "-startup-brand-helper="+nonce)
cmd.Env = append(os.Environ(),
"GO_TEST_STARTUP_BRAND_HELPER=1",
"LARKSUITE_CLI_CONFIG_DIR="+tmp,
"LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build
)
@@ -111,33 +85,3 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
t.Errorf("registry brand after real startup order = %s, want lark", out)
}
}
func TestStartupBrandHelperRequiresMatchingCommandNonce(t *testing.T) {
for _, tt := range []struct {
name string
envNonce string
argNonce string
want bool
}{
{name: "neither set"},
{name: "ambient environment only", envNonce: "ambient"},
{name: "command argument only", argNonce: "command"},
{name: "mismatch", envNonce: "ambient", argNonce: "command"},
{name: "matching", envNonce: "nonce", argNonce: "nonce", want: true},
} {
t.Run(tt.name, func(t *testing.T) {
if got := startupBrandHelperEnabled(tt.envNonce, tt.argNonce); got != tt.want {
t.Fatalf("startupBrandHelperEnabled() = %v, want %v", got, tt.want)
}
})
}
}
func TestStartupBrandHelperNonce(t *testing.T) {
if got := startupBrandHelperNonce([]string{"test", "-test.run", "brand"}); got != "" {
t.Fatalf("startupBrandHelperNonce() = %q, want empty", got)
}
if got := startupBrandHelperNonce([]string{"test", "-startup-brand-helper=nonce"}); got != "nonce" {
t.Fatalf("startupBrandHelperNonce() = %q, want nonce", got)
}
}

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"os"
"testing"
"github.com/larksuite/cli/internal/registry/registrytest"
)
// TestMain isolates command-tree tests from the host machine: config (and the
// registry cache under it) is redirected to a temp dir, then the registry is
// seeded from the tracked fixture and initialized eagerly. Tests pass on a
// clean checkout with no network, no `make fetch_meta`, and no user cache.
//
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
// m.Run before exiting.
func TestMain(m *testing.M) {
if isStartupBrandHelper() {
// Re-exec helper subprocess (startup_brand_test.go): the parent test
// already provides an isolated config dir and disables remote metadata,
// and the helper must own the first registry Init to prove the startup
// order — do not seed or eagerly initialize here.
os.Exit(m.Run())
}
root, err := os.MkdirTemp("", "lark-cli-cmd-test-*")
if err != nil {
println("cmd test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
println("cmd test setup: Setenv failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
if err := registrytest.Seed(root); err != nil {
println("cmd test setup: registrytest.Seed failed:", err.Error())
os.RemoveAll(root)
os.Exit(2)
}
code := m.Run()
os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdupdate
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-update-test-*")
if err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -24,8 +24,6 @@ import (
"github.com/larksuite/cli/internal/skillscheck"
)
const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
// newTestFactory creates a test factory with minimal config.
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
@@ -33,17 +31,13 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
return f, stdout, stderr
}
// mockDetect sets up newUpdater to return an Updater with the given DetectResult
// and fully mocked skills operations. Tests that only care about install-method
// detection must never fall through to the real npx skills CLI.
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
@@ -110,18 +104,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
}
}
func mockSkillsSync(t *testing.T) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
@@ -246,9 +228,6 @@ func TestNormalizeVersion(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mockSkillsSync(t)
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -277,9 +256,6 @@ func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
}
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mockSkillsSync(t)
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
@@ -305,7 +281,6 @@ func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
}
func TestUpdateManual_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
@@ -337,7 +312,6 @@ func TestUpdateManual_JSON(t *testing.T) {
}
func TestUpdateManual_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
@@ -1187,7 +1161,6 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
}
called := false
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
called = true
return successfulSkillsCommand()(args...)
@@ -1204,10 +1177,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: successfulSkillsCommand(),
}
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
if got == nil || got.Err != nil {
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
@@ -1227,7 +1197,6 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
t.Fatal(err)
}
updater := &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("npx failed")
@@ -1544,133 +1513,28 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
}
}
// liveSkillsIsolationEnv is the single source of truth for the user-state
// directories a live skills test must redirect under the temporary home. It
// covers the CLI's own config, the agent homes the skills CLI installs into,
// the XDG dirs it derives paths from (XDG_STATE_HOME holds its global
// .skill-lock.json), and the npm/npx overrides that take precedence over
// HOME-derived defaults (both cases: npm reads npm_config_* case-insensitively).
func liveSkillsIsolationEnv(home string) map[string]string {
return map[string]string{
"HOME": home,
"USERPROFILE": home,
"APPDATA": filepath.Join(home, "AppData", "Roaming"),
"LOCALAPPDATA": filepath.Join(home, "AppData", "Local"),
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
"XDG_DATA_HOME": filepath.Join(home, ".local", "share"),
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
"CODEX_HOME": filepath.Join(home, ".codex"),
"CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude"),
"LARKSUITE_CLI_CONFIG_DIR": filepath.Join(home, ".lark-cli"),
"npm_config_cache": filepath.Join(home, ".npm-cache"),
"NPM_CONFIG_CACHE": filepath.Join(home, ".npm-cache"),
"npm_config_prefix": filepath.Join(home, ".npm-global"),
"NPM_CONFIG_PREFIX": filepath.Join(home, ".npm-global"),
"npm_config_userconfig": filepath.Join(home, ".npmrc"),
"NPM_CONFIG_USERCONFIG": filepath.Join(home, ".npmrc"),
}
}
func prepareLiveSkillsIntegration(t *testing.T) string {
t.Helper()
if os.Getenv(runLiveSkillsTestsEnv) != "1" {
t.Skipf("live skills integration test disabled; set %s=1 to run", runLiveSkillsTestsEnv)
}
home := t.TempDir()
for key, value := range liveSkillsIsolationEnv(home) {
t.Setenv(key, value)
}
return home
}
func TestPrepareLiveSkillsIntegration(t *testing.T) {
reachedAfterGate := false
t.Run("requires explicit opt-in", func(t *testing.T) {
t.Setenv(runLiveSkillsTestsEnv, "")
prepareLiveSkillsIntegration(t)
reachedAfterGate = true
})
if reachedAfterGate {
t.Fatal("prepareLiveSkillsIntegration continued without explicit opt-in")
}
t.Run("isolates user directories", func(t *testing.T) {
t.Setenv(runLiveSkillsTestsEnv, "1")
home := prepareLiveSkillsIntegration(t)
// Pin the isolation contract by key: removing a variable from
// liveSkillsIsolationEnv must fail this list, and every redirected
// value must live under the temporary home.
required := []string{
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME",
"CODEX_HOME", "CLAUDE_CONFIG_DIR", "LARKSUITE_CLI_CONFIG_DIR",
"npm_config_cache", "NPM_CONFIG_CACHE",
"npm_config_prefix", "NPM_CONFIG_PREFIX",
"npm_config_userconfig", "NPM_CONFIG_USERCONFIG",
}
env := liveSkillsIsolationEnv(home)
for _, key := range required {
expected, ok := env[key]
if !ok {
t.Errorf("liveSkillsIsolationEnv dropped required key %s", key)
continue
}
if !strings.HasPrefix(expected, home) {
t.Errorf("%s = %q escapes temporary home %q", key, expected, home)
}
if got := os.Getenv(key); got != expected {
t.Errorf("%s = %q, want %q", key, got, expected)
}
}
})
}
// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs
// lark-calendar into the isolated global skills dir, and returns the parsed
// global skills list. The caller opted in explicitly, so every missing
// precondition is a hard failure — skipping would report "nothing verified"
// as a green run.
func seedLiveSkillsGlobal(t *testing.T) []string {
t.Helper()
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI, so the test is skipped when
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Fatalf("live skills tests opted in but npx not found in PATH: %v", err)
t.Skipf("npx not found in PATH: %v", err)
}
// Three sequential npx runs against a cold cache (the isolated home starts
// empty) can be slow; with Fatal-on-timeout semantics the budget errs on
// the generous side.
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err)
}
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
t.Fatalf("failed to seed isolated global skills: %v", err)
t.Skipf("real skills CLI unavailable: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Fatalf("real global skills CLI unavailable: %v", err)
t.Skipf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if len(localSkills) == 0 {
t.Fatal("seeded lark-calendar but global skills list is empty")
}
if err := ctx.Err(); err != nil {
t.Fatalf("real skills CLI availability check timed out: %v", err)
t.Skipf("real skills CLI availability check timed out: %v", err)
}
return localSkills
}
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
// state file. It calls the real npx skills CLI and only runs with explicit
// opt-in. All user directories are redirected to a temporary home.
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
prepareLiveSkillsIntegration(t)
// Phase 1: Verify the real npx skills CLI is available and seed the
// isolated global skills install.
localSkills := seedLiveSkillsGlobal(t)
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
@@ -1766,17 +1630,26 @@ func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
// not exist (cold start), the update command installs all official skills and
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
// because there is no previous state to preserve user deletions from.
// This is a live integration test that calls the real npx skills CLI and only
// runs with explicit opt-in. All user directories are redirected to a temporary
// home.
// This is a live integration test that calls the real npx skills CLI; it is
// skipped when npx or the skills registry is unavailable.
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
prepareLiveSkillsIntegration(t)
// Phase 1: Verify the real npx skills CLI is available and seed one known
// official skill into the isolated global install. Cold start means no
// skills-state.json — locally installed skills may still exist, and seeding
// one keeps the Phase 4 per-skill assertions from running zero times.
localSkills := seedLiveSkillsGlobal(t)
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
if _, err := exec.LookPath("npx"); err != nil {
t.Skipf("npx not found in PATH: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
t.Skipf("real skills CLI unavailable: %v", err)
}
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
if err != nil {
t.Skipf("real global skills CLI unavailable: %v", err)
}
localSkills := skillscheck.ParseSkillsList(string(globalOut))
if err := ctx.Err(); err != nil {
t.Skipf("real skills CLI availability check timed out: %v", err)
}
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -1,107 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
type BotMenuOutput struct {
Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
}
func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
AppID string `json:"app_id"`
TenantKey string `json:"tenant_key"`
} `json:"header"`
Event struct {
EventKey string `json:"event_key"`
Timestamp json.RawMessage `json:"timestamp"`
Operator struct {
OperatorID struct {
OpenID string `json:"open_id"`
UnionID string `json:"union_id"`
UserID string `json:"user_id"`
} `json:"operator_id"`
OperatorName string `json:"operator_name"`
} `json:"operator"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
timestamp := envelope.Header.CreateTime
if timestamp == "" {
timestamp = menuTimestamp
}
operatorID := envelope.Event.Operator.OperatorID.OpenID
out := &BotMenuOutput{
Type: eventTypeBotMenuV6,
EventID: envelope.Header.EventID,
Timestamp: timestamp,
AppID: envelope.Header.AppID,
TenantKey: envelope.Header.TenantKey,
EventKey: envelope.Event.EventKey,
MenuTimestamp: menuTimestamp,
OperatorID: operatorID,
OperatorOpenID: operatorID,
OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
OperatorName: envelope.Event.Operator.OperatorName,
}
return json.Marshal(out)
}
func rawScalarString(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" {
return ""
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return text
}
return s
}
func timestampMillisString(raw json.RawMessage) string {
s := rawScalarString(raw)
if len(s) == 10 && allDigits(s) {
return s + "000"
}
return s
}
func allDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}

View File

@@ -1,227 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestKeysBotMenuMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeBotMenuV6 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
}
if def.EventType != eventTypeBotMenuV6 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
}
if def.SubscriptionType != "" {
t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
}
if def.Schema.Custom == nil {
t.Fatal("Schema.Custom is nil")
}
if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
}
if def.Schema.Native != nil {
t.Fatal("Schema.Native must be nil for processed output")
}
if def.Process == nil {
t.Fatal("Process is nil")
}
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestBotMenuRegistersCleanly(t *testing.T) {
const key = eventTypeBotMenuV6
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}
func TestProcessBotMenu(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_001",
"event_type": "application.bot.menu_v6",
"create_time": "1776409469273",
"app_id": "cli_test",
"tenant_key": "tenant_test"
},
"event": {
"event_key": "start_eval",
"timestamp": 1776409469000,
"operator": {
"operator_id": {
"open_id": "ou_operator",
"union_id": "on_operator",
"user_id": "user_operator"
},
"operator_name": "Test User"
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
if out.EventID != "ev_menu_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
if out.EventKey != "start_eval" {
t.Errorf("EventKey = %q", out.EventKey)
}
if out.MenuTimestamp != "1776409469000" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
}
if out.OperatorUnionID != "on_operator" {
t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
}
if out.OperatorUserID != "user_operator" {
t.Errorf("OperatorUserID = %q", out.OperatorUserID)
}
if out.OperatorName != "Test User" {
t.Errorf("OperatorName = %q", out.OperatorName)
}
if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
}
}
func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_002",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": "1776409469001",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1776409469001" {
t.Errorf("Timestamp fallback = %q", out.Timestamp)
}
if out.MenuTimestamp != "1776409469001" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
}
func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_seconds",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": 1694592375,
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1694592375000" {
t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
}
if out.MenuTimestamp != "1694592375000" {
t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
}
}
func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_003",
"event_type": "unexpected.event_type",
"create_time": "1776409469275"
},
"event": {
"event_key": "start_eval",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
}
func TestProcessBotMenuMalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func runBotMenu(t *testing.T, payload string) BotMenuOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("processBotMenu: %v", err)
}
var out BotMenuOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("unmarshal output: %v\n%s", err, got)
}
return out
}

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package application registers Application-domain EventKeys.
package application
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeBotMenuV6 = "application.bot.menu_v6"
// Keys returns all Application-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeBotMenuV6,
DisplayName: "Bot menu",
Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
EventType: eventTypeBotMenuV6,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
},
Process: processBotMenu,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{eventTypeBotMenuV6},
},
}
}

View File

@@ -1,155 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
type approvalEventType string
type approvalSubscriptionPath string
type approvalSubscriptionConfig struct {
eventType approvalEventType
subscribePath approvalSubscriptionPath
}
func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
eventType := string(cfg.eventType)
subscribePath := string(cfg.subscribePath)
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
if err != nil {
return nil, err
}
registered := make([]string, 0, len(subscriptionTypes))
for _, subscriptionType := range subscriptionTypes {
body := map[string]string{"subscription_type": subscriptionType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, approvalSubscriptionRegistrationError(eventType, registered, subscriptionType, err)
}
registered = append(registered, subscriptionType)
}
// Approval subscriptions are durable user-auth relations. Consuming events
// should not cancel that relation when this local process exits.
return nil, nil
}
}
func approvalSubscriptionTypes(eventType string, params map[string]string) ([]string, error) {
raw := strings.TrimSpace(params["subscription_type"])
if raw == "" {
return append([]string(nil), approvalAllSubscriptionTypes...), nil
}
values, err := parseApprovalSubscriptionTypeValues(raw)
if err != nil {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
selected := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
switch value {
case approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged:
selected[value] = true
default:
return nil, invalidApprovalSubscriptionTypeError(eventType, value)
}
}
result := make([]string, 0, len(selected))
for _, value := range approvalAllSubscriptionTypes {
if selected[value] {
result = append(result, value)
}
}
if len(result) == 0 {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
return result, nil
}
func parseApprovalSubscriptionTypeValues(raw string) ([]string, error) {
if strings.HasPrefix(raw, "[") {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
return values, nil
}
return strings.Split(raw, ","), nil
}
func approvalSubscriptionRegistrationError(eventType string, registered []string, failed string, err error) error {
if err == nil {
return nil
}
msg := fmt.Sprintf(
"approval subscription pre-consume failed for EventKey %s: failed subscription_type %s",
eventType,
failed,
)
hint := fmt.Sprintf(
"no approval subscription relation was registered for EventKey %s; fix the cause and retry",
eventType,
)
if len(registered) > 0 {
msg = fmt.Sprintf(
"approval subscription pre-consume partially completed for EventKey %s: registered subscription_type(s) [%s], failed subscription_type %s",
eventType,
strings.Join(registered, ", "),
failed,
)
hint = fmt.Sprintf(
"server-side approval subscription relation(s) already registered for EventKey %s: %s; after fixing the cause, retry with --param subscription_type=%s to register the failed relation",
eventType,
strings.Join(registered, ", "),
failed,
)
}
if p, ok := errs.ProblemOf(err); ok {
if upstream := strings.TrimSpace(p.Message); upstream != "" {
p.Message = msg + ": " + upstream
} else {
p.Message = msg
}
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
p.Hint = upstreamHint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).
WithHint("%s", hint).
WithCause(err)
}
func invalidApprovalSubscriptionTypeError(eventType, value string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid subscription_type for EventKey %s: %q", eventType, value).
WithParam("--param").
WithHint("omit subscription_type to register both approval subscription relations, or pass --param subscription_type=%s, --param subscription_type=%s, or --param subscription_type=%s,%s; run `lark-cli event schema %s` for details",
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
eventType)
}

View File

@@ -1,179 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package approval registers Approval-domain EventKeys.
package approval
import (
"context"
"encoding/json"
"reflect"
"github.com/larksuite/cli/internal/event"
)
const (
eventTypeApprovalInstanceStatusChangedV4 = "approval.instance.status_changed_v4"
eventTypeApprovalTaskStatusChangedV4 = "approval.task.status_changed_v4"
pathApprovalInstancesSubscription = "/open-apis/approval/v4/instances/subscription"
pathApprovalTasksSubscription = "/open-apis/approval/v4/tasks/subscription"
approvalSubscriptionTypeInvolved = "INVOLVED_APPROVAL"
approvalSubscriptionTypeManaged = "MANAGED_APPROVAL"
)
var approvalAllSubscriptionTypes = []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
}
// Keys returns all Approval-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeApprovalInstanceStatusChangedV4,
DisplayName: "Approval instance status changed",
Description: "Triggered after an approval instance status becomes visible to the requester or approval participants",
EventType: eventTypeApprovalInstanceStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
},
Process: processApprovalInstanceStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
}),
Scopes: []string{"approval:instance:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalInstanceStatusChangedV4},
},
{
Key: eventTypeApprovalTaskStatusChangedV4,
DisplayName: "Approval task status changed",
Description: "Triggered after an approval task status becomes visible to the requester or task approver",
EventType: eventTypeApprovalTaskStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
},
Process: processApprovalTaskStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
}),
Scopes: []string{"approval:task:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalTaskStatusChangedV4},
},
}
}
func approvalSubscriptionParams() []event.ParamDef {
return []event.ParamDef{
{
Name: "subscription_type",
Type: event.ParamMulti,
Description: "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.",
Values: []event.ParamValue{
{
Value: approvalSubscriptionTypeInvolved,
Desc: "Receive events where the current user is the approval requester or approver.",
},
{
Value: approvalSubscriptionTypeManaged,
Desc: "Receive events under approval definitions managed by the current user.",
},
},
},
}
}
func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
ExternalID string `json:"external_id"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
StartUser *ApprovalUserID `json:"start_user"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &ApprovalInstanceStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
ExternalID: envelope.Event.ExternalID,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
StartUser: envelope.Event.StartUser,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}
func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
TaskID string `json:"task_id"`
ExternalID string `json:"external_id"`
TaskExternalID string `json:"task_external_id"`
AssignedUser *ApprovalUserID `json:"assigned_user"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &ApprovalTaskStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
TaskID: envelope.Event.TaskID,
ExternalID: envelope.Event.ExternalID,
TaskExternalID: envelope.Event.TaskExternalID,
AssignedUser: envelope.Event.AssignedUser,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -1,654 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
type recordedCall struct {
method string
path string
body interface{}
}
type fakeAPIClient struct {
calls []recordedCall
err error
errOnCall int
}
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
if f.err != nil && (f.errOnCall == 0 || f.errOnCall == len(f.calls)) {
return nil, f.err
}
return json.RawMessage(`{}`), nil
}
func TestKeysApprovalMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 2 {
t.Fatalf("len(Keys()) = %d, want 2", len(keys))
}
tests := []struct {
key string
scope string
schemaType reflect.Type
subscribe string
}{
{
key: eventTypeApprovalInstanceStatusChangedV4,
scope: "approval:instance:read",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
subscribe: pathApprovalInstancesSubscription,
},
{
key: eventTypeApprovalTaskStatusChangedV4,
scope: "approval:task:read",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
subscribe: pathApprovalTasksSubscription,
},
}
byKey := make(map[string]event.KeyDefinition, len(keys))
for _, def := range keys {
byKey[def.Key] = def
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
def, ok := byKey[tc.key]
if !ok {
t.Fatalf("missing key %s", tc.key)
}
if def.EventType != tc.key {
t.Errorf("EventType = %q, want %q", def.EventType, tc.key)
}
if def.Schema.Custom == nil || def.Schema.Custom.Type != tc.schemaType {
t.Fatalf("Custom schema Type = %v, want %v", def.Schema.Custom, tc.schemaType)
}
if def.Schema.Native != nil {
t.Fatal("approval events must use Custom schema while SDK event types are not exported")
}
if def.Process == nil {
t.Fatal("Process must flatten raw V2 envelopes")
}
if def.PreConsume == nil {
t.Fatal("PreConsume must subscribe approval user-auth events")
}
if !reflect.DeepEqual(def.Scopes, []string{tc.scope}) {
t.Errorf("Scopes = %#v, want %q", def.Scopes, tc.scope)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user"}) {
t.Errorf("AuthTypes = %#v, want user", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{tc.key}) {
t.Errorf("RequiredConsoleEvents = %#v, want %q", def.RequiredConsoleEvents, tc.key)
}
assertSubscriptionParam(t, def.Params)
})
}
}
func assertSubscriptionParam(t *testing.T, params []event.ParamDef) {
t.Helper()
if len(params) != 1 {
t.Fatalf("len(params) = %d, want 1", len(params))
}
p := params[0]
if p.Name != "subscription_type" || p.Type != event.ParamMulti || p.Required || p.SubscriptionKey {
t.Fatalf("subscription_type param = %+v, want optional multi non-subscription-key param", p)
}
got := map[string]string{}
for _, v := range p.Values {
got[v.Value] = v.Desc
}
for _, want := range []string{approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged} {
if got[want] == "" {
t.Errorf("subscription_type value %q missing or empty desc; values=%+v", want, p.Values)
}
}
}
type reflectedApprovalSchema struct {
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
type reflectedApprovalSchemaProperty struct {
Format string `json:"format"`
Enum []string `json:"enum"`
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
func TestApprovalSchemasAnnotations(t *testing.T) {
tests := []struct {
name string
schemaType reflect.Type
eventType string
statusValues []string
userField string
}{
{
name: "instance",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
eventType: eventTypeApprovalInstanceStatusChangedV4,
statusValues: []string{"PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "start_user",
},
{
name: "task",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
eventType: eventTypeApprovalTaskStatusChangedV4,
statusValues: []string{"REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "assigned_user",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var schema reflectedApprovalSchema
if err := json.Unmarshal(schemas.FromType(tc.schemaType), &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
props := schema.Properties
eventTypeEnum := props["type"].Enum
if len(eventTypeEnum) != 1 || eventTypeEnum[0] != tc.eventType {
t.Fatalf("type enum = %v, want %s", eventTypeEnum, tc.eventType)
}
if got := props["timestamp"].Format; got != "timestamp_ms" {
t.Errorf("timestamp format = %v, want timestamp_ms", got)
}
assertEnumContains(t, props["status"].Enum, tc.statusValues)
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("event.operate_time format = %v, want timestamp_ms", got)
}
userProps := props[tc.userField].Properties
if got := userProps["open_id"].Format; got != "open_id" {
t.Errorf("%s.open_id format = %v, want open_id", tc.userField, got)
}
if got := userProps["union_id"].Format; got != "union_id" {
t.Errorf("%s.union_id format = %v, want union_id", tc.userField, got)
}
if got := userProps["user_id"].Format; got != "user_id" {
t.Errorf("%s.user_id format = %v, want user_id", tc.userField, got)
}
})
}
}
func assertEnumContains(t *testing.T, raw []string, wants []string) {
t.Helper()
got := make(map[string]bool, len(raw))
for _, v := range raw {
got[v] = true
}
for _, want := range wants {
if !got[want] {
t.Errorf("enum missing %q; enum=%v", want, raw)
}
}
}
func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T) {
tests := []struct {
name string
eventType string
subscribePath string
params map[string]string
wantTypes []string
}{
{
name: "instance omitted subscription_type registers both",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "task explicit single managed",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{"subscription_type": approvalSubscriptionTypeManaged},
wantTypes: []string{approvalSubscriptionTypeManaged},
},
{
name: "task comma separated multi canonicalizes and deduplicates",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{
"subscription_type": approvalSubscriptionTypeManaged + "," + approvalSubscriptionTypeInvolved + "," + approvalSubscriptionTypeManaged,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "instance json array multi",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
params: map[string]string{
"subscription_type": `["MANAGED_APPROVAL","INVOLVED_APPROVAL"]`,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: approvalEventType(tc.eventType),
subscribePath: approvalSubscriptionPath(tc.subscribePath),
})
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, tc.params)
if err != nil {
t.Fatalf("PreConsume returned error: %v", err)
}
if cleanup != nil {
t.Fatal("cleanup must be nil; approval consume must not unsubscribe on exit")
}
assertSubscriptionCalls(t, rt.calls, tc.subscribePath, tc.wantTypes)
})
}
}
func assertSubscriptionCalls(t *testing.T, got []recordedCall, wantPath string, wantTypes []string) {
t.Helper()
if len(got) != len(wantTypes) {
t.Fatalf("calls after pre-consume = %d, want %d; calls=%+v", len(got), len(wantTypes), got)
}
for i, wantType := range wantTypes {
assertCall(t, got[i], "POST", wantPath, map[string]string{"subscription_type": wantType})
}
}
func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wantBody interface{}) {
t.Helper()
if got.method != wantMethod {
t.Errorf("method = %q, want %q", got.method, wantMethod)
}
if got.path != wantPath {
t.Errorf("path = %q, want %q", got.path, wantPath)
}
if !reflect.DeepEqual(got.body, wantBody) {
t.Errorf("body = %#v, want %#v", got.body, wantBody)
}
}
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
t.Run("nil runtime", func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
if err == nil {
t.Fatal("expected nil runtime error")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryInternal {
t.Fatalf("err = %T/%v, want typed internal error", err, err)
}
})
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
t.Run("invalid subscription type "+raw, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
if err == nil {
t.Fatal("expected invalid subscription_type error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T/%v, want *errs.ValidationError", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want invalid_argument/--param", ve.Subtype, ve.Param)
}
if ve.Hint == "" {
t.Error("invalid subscription_type should carry a hint")
}
})
}
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
})
cleanup, err := pc(context.Background(), rt, map[string]string{})
if err == nil {
t.Fatal("expected partial registration error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on registration error")
}
assertSubscriptionCalls(t, rt.calls, pathApprovalTasksSubscription, []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Fatalf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
}
for _, want := range []string{
"registered subscription_type(s) [INVOLVED_APPROVAL]",
"failed subscription_type MANAGED_APPROVAL",
} {
if !strings.Contains(p.Message, want) {
t.Errorf("partial error message missing %q: %q", want, p.Message)
}
}
for _, want := range []string{
"already registered",
"--param subscription_type=MANAGED_APPROVAL",
} {
if !strings.Contains(p.Hint, want) {
t.Errorf("partial error hint missing %q: %q", want, p.Hint)
}
}
})
}
func TestApprovalSubscriptionRegistrationErrorVariants(t *testing.T) {
t.Run("nil error", func(t *testing.T) {
if err := approvalSubscriptionRegistrationError(eventTypeApprovalTaskStatusChangedV4, nil, approvalSubscriptionTypeInvolved, nil); err != nil {
t.Fatalf("nil cause returned error: %v", err)
}
})
t.Run("typed error with existing hint and empty message", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "").WithHint("retry later")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
upstream,
)
if err != upstream {
t.Fatalf("typed error should be annotated in place; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if !strings.Contains(p.Message, "failed subscription_type INVOLVED_APPROVAL") {
t.Errorf("message missing failed relation: %q", p.Message)
}
for _, want := range []string{"retry later", "no approval subscription relation was registered"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint missing %q: %q", want, p.Hint)
}
}
})
t.Run("untyped error is wrapped with retry context", func(t *testing.T) {
cause := errors.New("transport closed")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
cause,
)
if !errors.Is(err, cause) {
t.Fatalf("wrapped error should preserve cause; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeSDKError {
t.Fatalf("category/subtype = %s/%s, want internal/sdk_error", p.Category, p.Subtype)
}
if !strings.Contains(p.Hint, "no approval subscription relation was registered") {
t.Errorf("hint missing no-registration context: %q", p.Hint)
}
})
}
func TestProcessApprovalInstanceStatusChanged(t *testing.T) {
out := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_001",
"event_type": "approval.instance.status_changed_v4",
"create_time": "1710000000000"
},
"event": {
"approval_code": "approval_code_001",
"instance_code": "instance_code_001",
"external_id": "external_001",
"status": "PENDING",
"operate_time": "1666079207003",
"start_user": {
"open_id": "ou_start",
"union_id": "on_start",
"user_id": "user_start"
}
}
}`)
if out.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalInstanceStatusChangedV4)
}
if out.EventID != "evt_approval_instance_001" || out.Timestamp != "1710000000000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_001" || out.InstanceCode != "instance_code_001" {
t.Errorf("approval/instance code = %q/%q", out.ApprovalCode, out.InstanceCode)
}
if out.ExternalID != "external_001" || out.Status != "PENDING" || out.OperateTime != "1666079207003" {
t.Errorf("external/status/operate_time = %q/%q/%q", out.ExternalID, out.Status, out.OperateTime)
}
if out.StartUser == nil || out.StartUser.OpenID != "ou_start" || out.StartUser.UnionID != "on_start" || out.StartUser.UserID != "user_start" {
t.Fatalf("StartUser = %+v, want full user ids", out.StartUser)
}
}
func TestProcessApprovalTaskStatusChanged(t *testing.T) {
out := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_001",
"event_type": "approval.task.status_changed_v4",
"create_time": "1710000000001"
},
"event": {
"approval_code": "approval_code_002",
"instance_code": "instance_code_002",
"task_id": "task_001",
"external_id": "external_002",
"task_external_id": "task_external_001",
"status": "APPROVED",
"operate_time": "1666079207004",
"assigned_user": {
"open_id": "ou_assignee",
"union_id": "on_assignee",
"user_id": "user_assignee"
}
}
}`)
if out.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalTaskStatusChangedV4)
}
if out.EventID != "evt_approval_task_001" || out.Timestamp != "1710000000001" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_002" || out.InstanceCode != "instance_code_002" || out.TaskID != "task_001" {
t.Errorf("approval/instance/task = %q/%q/%q", out.ApprovalCode, out.InstanceCode, out.TaskID)
}
if out.ExternalID != "external_002" || out.TaskExternalID != "task_external_001" || out.Status != "APPROVED" || out.OperateTime != "1666079207004" {
t.Errorf("external/task_external/status/operate_time = %q/%q/%q/%q", out.ExternalID, out.TaskExternalID, out.Status, out.OperateTime)
}
if out.AssignedUser == nil || out.AssignedUser.OpenID != "ou_assignee" || out.AssignedUser.UnionID != "on_assignee" || out.AssignedUser.UserID != "user_assignee" {
t.Fatalf("AssignedUser = %+v, want full user ids", out.AssignedUser)
}
}
func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
instance := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_fallback",
"create_time": "1710000000002"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"status": "APPROVED",
"operate_time": "1666079207005"
}
}`)
if instance.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("instance Type fallback = %q, want %q", instance.Type, eventTypeApprovalInstanceStatusChangedV4)
}
task := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_fallback",
"create_time": "1710000000003"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"task_id": "task_fallback",
"status": "DONE",
"operate_time": "1666079207006"
}
}`)
if task.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("task Type fallback = %q, want %q", task.Type, eventTypeApprovalTaskStatusChangedV4)
}
}
func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"instance", eventTypeApprovalInstanceStatusChangedV4, processApprovalInstanceStatusChanged},
{"task", eventTypeApprovalTaskStatusChangedV4, processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
raw := &event.RawEvent{
EventType: tc.eventType,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
})
}
}
func TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
for _, tc := range []struct {
name string
process event.ProcessFunc
}{
{"instance", processApprovalInstanceStatusChanged},
{"task", processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.process(context.Background(), nil, nil, nil)
if err != nil {
t.Fatalf("Process nil raw returned error: %v", err)
}
if got != nil {
t.Fatalf("Process nil raw output = %s, want nil", string(got))
}
})
}
}
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalInstanceStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalInstanceStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid instance JSON: %v\nraw=%s", err, string(got))
}
return out
}
func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalTaskStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalTaskStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid task JSON: %v\nraw=%s", err, string(got))
}
return out
}
func TestApprovalKeysRegisterCleanly(t *testing.T) {
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
}
for _, def := range Keys() {
event.RegisterKey(def)
}
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}
}
var _ event.APIClient = (*fakeAPIClient)(nil)

View File

@@ -1,42 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
// ApprovalUserID identifies a user in the three Lark ID formats included by
// approval status-change events.
type ApprovalUserID struct {
OpenID string `json:"open_id,omitempty" desc:"User open_id; prefixed with ou_" kind:"open_id"`
UnionID string `json:"union_id,omitempty" desc:"User union_id" kind:"union_id"`
UserID string `json:"user_id,omitempty" desc:"User id within the tenant" kind:"user_id"`
}
// ApprovalInstanceStatusChangedV4Output is the flattened shape for
// approval.instance.status_changed_v4.
type ApprovalInstanceStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.instance.status_changed_v4" enum:"approval.instance.status_changed_v4"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval instance id; present only for third-party approvals"`
Status string `json:"status,omitempty" desc:"Approval instance status" enum:"PENDING,APPROVED,REJECTED,CANCELED,DELETED,REVERTED,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
StartUser *ApprovalUserID `json:"start_user,omitempty" desc:"Approval instance starter; omitted when unavailable"`
}
// ApprovalTaskStatusChangedV4Output is the flattened shape for
// approval.task.status_changed_v4.
type ApprovalTaskStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.task.status_changed_v4" enum:"approval.task.status_changed_v4"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
TaskID string `json:"task_id,omitempty" desc:"Approval task id"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval external id; present only for third-party approvals"`
TaskExternalID string `json:"task_external_id,omitempty" desc:"Third-party approval task external id; present only when emitted by the upstream service"`
AssignedUser *ApprovalUserID `json:"assigned_user,omitempty" desc:"Task assignee or operator user ids; omitted for automatic flows without an operator"`
Status string `json:"status,omitempty" desc:"Approval task status" enum:"REVERTED,PENDING,APPROVED,REJECTED,TRANSFERRED,ROLLBACK,DONE,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
}

View File

@@ -13,29 +13,17 @@ import (
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
type ImMessageReceiveOutput struct {
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
EventID string `json:"event_id,omitempty" desc:"Event delivery ID. Do not use as the message deduplication key; use message_id instead."`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers." kind:"message_id"`
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
UpdateTime string `json:"update_time,omitempty" desc:"Message update time (ms timestamp string); emitted only when different from create_time" kind:"timestamp_ms"`
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
SenderType string `json:"sender_type,omitempty" desc:"Sender type" enum:"user,bot"`
RootID string `json:"root_id,omitempty" desc:"Root message ID of the reply/thread context, when present" kind:"message_id"`
ThreadID string `json:"thread_id,omitempty" desc:"Thread ID, when present"`
ReplyTo string `json:"reply_to,omitempty" desc:"Parent message ID of the direct reply context, when present" kind:"message_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
Mentions []MentionOutput `json:"mentions,omitempty" desc:"Compact mentions aligned with im +messages-mget"`
}
type MentionOutput struct {
Key string `json:"key,omitempty" desc:"Mention placeholder key, for example @_user_1"`
ID string `json:"id,omitempty" desc:"Mentioned user open_id; prefixed with ou_" kind:"open_id"`
Name string `json:"name,omitempty" desc:"Mentioned display name"`
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -48,20 +36,15 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
Event struct {
Message struct {
MessageID string `json:"message_id"`
RootID string `json:"root_id"`
ParentID string `json:"parent_id"`
ThreadID string `json:"thread_id"`
ChatID string `json:"chat_id"`
ChatType string `json:"chat_type"`
MessageType string `json:"message_type"`
Content string `json:"content"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Mentions []interface{} `json:"mentions"`
} `json:"message"`
Sender struct {
SenderType string `json:"sender_type"`
SenderID struct {
SenderID struct {
OpenID string `json:"open_id"`
} `json:"sender_id"`
} `json:"sender"`
@@ -98,54 +81,7 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
ChatType: msg.ChatType,
MessageType: msg.MessageType,
SenderID: envelope.Event.Sender.SenderID.OpenID,
SenderType: envelope.Event.Sender.SenderType,
RootID: msg.RootID,
ThreadID: msg.ThreadID,
ReplyTo: msg.ParentID,
Content: content,
Mentions: compactMentions(msg.Mentions),
}
if msg.UpdateTime != "" && msg.UpdateTime != msg.CreateTime {
out.UpdateTime = msg.UpdateTime
}
return json.Marshal(out)
}
func compactMentions(mentions []interface{}) []MentionOutput {
if len(mentions) == 0 {
return nil
}
out := make([]MentionOutput, 0, len(mentions))
for _, raw := range mentions {
item, _ := raw.(map[string]interface{})
mention := MentionOutput{
Key: stringField(item, "key"),
ID: mentionOpenID(item["id"]),
Name: stringField(item, "name"),
}
if mention.Key != "" || mention.ID != "" || mention.Name != "" {
out = append(out, mention)
}
}
if len(out) == 0 {
return nil
}
return out
}
func stringField(m map[string]interface{}, key string) string {
v, _ := m[key].(string)
return v
}
func mentionOpenID(raw interface{}) string {
switch v := raw.(type) {
case map[string]interface{}:
openID, _ := v["open_id"].(string)
return openID
case string:
return v
default:
return ""
}
}

View File

@@ -84,32 +84,19 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
},
"event": {
"sender": {
"sender_type": "user",
"sender_id": {"open_id": "ou_sender"}
},
"message": {
"message_id": "om_text_001",
"root_id": "om_root_001",
"parent_id": "om_parent_001",
"thread_id": "omt_thread_001",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": "1776409468987",
"update_time": "1776409469999",
"content": "{\"text\":\"hello @_user_1\"}",
"mentions": [
{
"key": "@_user_1",
"id": {"open_id": "ou_mentioned"},
"name": "Alice"
}
]
"content": "{\"text\":\"hello there\"}"
}
}
}`
out := runReceive(t, payload)
outMap := runReceiveMap(t, payload)
if out.Type != "im.message.receive_v1" {
t.Errorf("Type = %q", out.Type)
@@ -123,69 +110,12 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
if out.SenderID != "ou_sender" {
t.Errorf("SenderID = %q", out.SenderID)
}
if out.Content != "hello @Alice" {
t.Errorf("Content = %q, want \"hello @Alice\"", out.Content)
if out.Content != "hello there" {
t.Errorf("Content = %q, want \"hello there\"", out.Content)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
for field, want := range map[string]string{
"sender_type": "user",
"root_id": "om_root_001",
"thread_id": "omt_thread_001",
"reply_to": "om_parent_001",
"update_time": "1776409469999",
} {
if got, _ := outMap[field].(string); got != want {
t.Errorf("%s = %q, want %q", field, got, want)
}
}
mentions, _ := outMap["mentions"].([]interface{})
if len(mentions) != 1 {
t.Fatalf("mentions length = %d, want 1: %#v", len(mentions), outMap["mentions"])
}
mention, _ := mentions[0].(map[string]interface{})
for field, want := range map[string]string{
"key": "@_user_1",
"id": "ou_mentioned",
"name": "Alice",
} {
if got, _ := mention[field].(string); got != want {
t.Errorf("mentions[0].%s = %q, want %q", field, got, want)
}
}
}
func TestProcessImMessageReceive_OmitsUnchangedUpdateTime(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_test_text",
"event_type": "im.message.receive_v1",
"create_time": "1776409469273",
"app_id": "cli_test"
},
"event": {
"sender": {
"sender_type": "user",
"sender_id": {"open_id": "ou_sender"}
},
"message": {
"message_id": "om_text_001",
"chat_id": "oc_chat",
"chat_type": "p2p",
"message_type": "text",
"create_time": "1776409468987",
"update_time": "1776409468987",
"content": "{\"text\":\"hello there\"}"
}
}
}`
outMap := runReceiveMap(t, payload)
if _, ok := outMap["update_time"]; ok {
t.Errorf("update_time should be omitted when it equals create_time: %#v", outMap)
}
}
func TestProcessImMessageReceive_Interactive(t *testing.T) {
@@ -258,22 +188,3 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
}
return out
}
func runReceiveMap(t *testing.T, payload string) map[string]interface{} {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: "im.message.receive_v1",
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
}
return out
}

View File

@@ -5,8 +5,6 @@
package events
import (
"github.com/larksuite/cli/events/application"
"github.com/larksuite/cli/events/approval"
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
@@ -18,8 +16,6 @@ import (
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
application.Keys(),
approval.Keys(),
im.Keys(),
minutes.Keys(),
task.Keys(),

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-internal-auth-test-*")
if err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -132,14 +132,16 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
})
}
emitter := output.NewEmitter(output.EmitterConfig{
Out: opts.Out,
ErrOut: opts.ErrOut,
CommandPath: opts.CommandPath,
Identity: string(identity),
NoticeProvider: output.GetNotice,
})
return emitter.Success(result, output.EmitOptions{Format: opts.Format.String()})
// Content safety scanning for non-JSON presentation formats.
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
output.FormatValue(opts.Out, result, opts.Format)
return nil
}
// Non-JSON (binary) responses.

View File

@@ -18,7 +18,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
@@ -240,87 +239,6 @@ func TestHandleResponse_JSON(t *testing.T) {
}
}
func TestHandleResponse_NonJSONFormatsEmitExactStructuredResponseBytes(t *testing.T) {
tests := []struct {
name string
format output.Format
want string
}{
{
name: "ndjson",
format: output.FormatNDJSON,
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Bob\"}\n",
},
{
name: "table",
format: output.FormatTable,
want: "id name \n── ─────\n1 Alice\n2 Bob \n",
},
{
name: "csv",
format: output.FormatCSV,
want: "id,name\n1,Alice\n2,Bob\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
reg := &httpmock.Registry{}
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/test/v1/items",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
map[string]interface{}{"id": "2", "name": "Bob"},
},
"has_more": false,
},
},
})
httpResp, err := httpmock.NewClient(reg).Get("https://open.feishu.cn/open-apis/test/v1/items")
if err != nil {
t.Fatalf("fixture request failed: %v", err)
}
body, err := io.ReadAll(httpResp.Body)
_ = httpResp.Body.Close()
if err != nil {
t.Fatalf("read fixture response: %v", err)
}
resp := &larkcore.ApiResp{
StatusCode: httpResp.StatusCode,
Header: httpResp.Header.Clone(),
RawBody: body,
}
var out bytes.Buffer
var errOut bytes.Buffer
err = HandleResponse(resp, ResponseOptions{
Format: tt.format,
Identity: core.AsBot,
Out: &out,
ErrOut: &errOut,
CommandPath: "lark-cli api GET",
})
if err != nil {
t.Fatalf("HandleResponse() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
}
if got := errOut.String(); got != "" {
t.Fatalf("stderr bytes = %q, want empty", got)
}
reg.Verify(t)
})
}
}
func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) {
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})

View File

@@ -14,16 +14,12 @@ import (
// with --yes.
//
// action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The hint is deliberately NOT a pre-built retry
// command: argv cannot faithfully reproduce the original invocation (pipeline
// producers, stdin bytes, redirections, inline env and the executable's real
// path are all gone), POSIX quoting does not survive PowerShell/cmd.exe, and
// echoing argv values can copy credentials or free-form payloads (--sql,
// --json) into the error envelope and every log that captures it. Per the
// lark-shared approval protocol, the caller that obtained the user's consent
// appends --yes to its own saved argv array and re-executes.
// "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls.
func RequireConfirmation(action string) error {
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action)
return err.WithHint("add --yes to confirm")
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action).
WithHint("add --yes to confirm")
}

View File

@@ -35,11 +35,8 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
}
// The hint is the plain add-yes contract and nothing more: no pre-built
// retry command may ride behind it (argv cannot faithfully reproduce the
// invocation and may carry sensitive payloads — see RequireConfirmation).
if cre.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want exactly 'add --yes to confirm'", cre.Hint)
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
}
if cre.Risk != errs.RiskHighRiskWrite {
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
@@ -64,8 +61,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
// No fix_command field leaks into the envelope: the typed protocol stays
// action-only.
// No fix_command field leaks into the envelope: the protocol avoids
// shell-quoting hazards by delegating retry to agent-side logic.
if _, has := back["fix_command"]; has {
t.Errorf("unexpected fix_command present in JSON: %s", raw)
}

View File

@@ -8,29 +8,15 @@ import (
"fmt"
"io"
"net/url"
"regexp"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
var dryRunURLPlaceholderRE = regexp.MustCompile(`:([A-Za-z_][A-Za-z0-9_]*)`)
// DryRunOutputOptions controls dry-run stdout/stderr rendering.
type DryRunOutputOptions struct {
Format string
JqExpr string
CommandPath string
Identity core.Identity
Out io.Writer
ErrOut io.Writer
}
// DryRunAPICall describes a single API call in dry-run output.
type DryRunAPICall struct {
Desc string `json:"desc,omitempty"`
@@ -40,21 +26,12 @@ type DryRunAPICall struct {
Body interface{} `json:"body,omitempty"`
}
// DryRunContext is the execution context shared by every dry-run preview:
// which app would make the call and, when known, as which user. The identity
// itself lives at the envelope top level, not here.
type DryRunContext struct {
AppID string `json:"app_id,omitempty"`
UserOpenID string `json:"user_open_id,omitempty"`
}
// DryRunAPI is the builder and result type for dry-run output.
// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them.
type DryRunAPI struct {
desc string
calls []DryRunAPICall
context *DryRunContext
extra map[string]interface{}
desc string
calls []DryRunAPICall
extra map[string]interface{}
}
func NewDryRunAPI() *DryRunAPI {
@@ -63,22 +40,30 @@ func NewDryRunAPI() *DryRunAPI {
// --- HTTP method builders (add a call, return self for chaining) ---
// call appends a request with the method transcribed verbatim, so previews
// never misreport what the real client would send.
func (d *DryRunAPI) call(method, url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: method, URL: url})
func (d *DryRunAPI) GET(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url})
return d
}
func (d *DryRunAPI) GET(url string) *DryRunAPI { return d.call("GET", url) }
func (d *DryRunAPI) POST(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url})
return d
}
func (d *DryRunAPI) POST(url string) *DryRunAPI { return d.call("POST", url) }
func (d *DryRunAPI) PUT(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url})
return d
}
func (d *DryRunAPI) PUT(url string) *DryRunAPI { return d.call("PUT", url) }
func (d *DryRunAPI) DELETE(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url})
return d
}
func (d *DryRunAPI) DELETE(url string) *DryRunAPI { return d.call("DELETE", url) }
func (d *DryRunAPI) PATCH(url string) *DryRunAPI { return d.call("PATCH", url) }
func (d *DryRunAPI) PATCH(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url})
return d
}
// Body sets the request body on the last added call.
func (d *DryRunAPI) Body(body interface{}) *DryRunAPI {
@@ -113,26 +98,12 @@ func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI {
return d
}
// Context records the calling app/user under data.context; empty values are
// omitted, and a fully empty context is not emitted at all.
func (d *DryRunAPI) Context(appID, userOpenID string) *DryRunAPI {
if appID == "" && userOpenID == "" {
return d
}
d.context = &DryRunContext{AppID: appID, UserOpenID: userOpenID}
return d
}
// resolveURL replaces :key placeholders in url with path-escaped values from extra.
func (d *DryRunAPI) resolveURL(rawURL string) string {
return dryRunURLPlaceholderRE.ReplaceAllStringFunc(rawURL, func(token string) string {
name := token[1:]
value, ok := d.extra[name]
if !ok {
return token
}
return url.PathEscape(fmt.Sprintf("%v", value))
})
for k, v := range d.extra {
rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v)))
}
return rawURL
}
// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}.
@@ -147,17 +118,13 @@ func (d *DryRunAPI) MarshalJSON() ([]byte, error) {
Body: c.Body,
}
}
m := make(map[string]interface{}, len(d.extra)+3)
for k, v := range d.extra {
m[k] = v
}
// Typed fields win over same-named extra keys.
m := make(map[string]interface{}, len(d.extra)+2)
if d.desc != "" {
m["description"] = d.desc
}
m["api"] = resolved
if d.context != nil {
m["context"] = d.context
for k, v := range d.extra {
m[k] = v
}
return json.Marshal(m)
}
@@ -187,7 +154,11 @@ func (d *DryRunAPI) Format() string {
u += "?" + encodeParams(c.Params)
}
b.WriteString(c.Method)
method := c.Method
if method == "" {
method = "GET"
}
b.WriteString(method)
b.WriteByte(' ')
b.WriteString(u)
b.WriteByte('\n')
@@ -244,74 +215,83 @@ func encodeParams(params map[string]interface{}) string {
return vals.Encode()
}
// buildDryRunPreview assembles the shared preview skeleton: HTTP method, URL,
// query params, and the app/user context common to every dry-run.
func buildDryRunPreview(request client.RawApiRequest, config *core.CliConfig) *DryRunAPI {
dr := NewDryRunAPI().call(request.Method, request.URL)
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
// Instead of serializing the Formdata body, it shows file metadata.
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
// Identity is reported at the envelope top level, not duplicated here.
dr.Context(config.AppID, config.UserOpenId)
return dr
}
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
// Instead of serializing the Formdata body, it shows file metadata.
func PrintDryRunWithFile(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions, file FileUploadMeta) error {
dr := buildDryRunPreview(request, config)
filePathDisplay := file.FilePath
filePathDisplay := filePath
if filePathDisplay == "" {
filePathDisplay = "<stdin>"
}
fileInfo := map[string]any{
"file": map[string]string{"field": file.FieldName, "path": filePathDisplay},
"file": map[string]string{"field": fileField, "path": filePathDisplay},
}
if file.FormFields != nil {
fileInfo["form_fields"] = file.FormFields
if formFields != nil {
fileInfo["form_fields"] = formFields
}
fileInfo["options"] = []string{"WithFileUpload"}
dr.Body(fileInfo)
return WriteDryRun(dr, opts)
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
}
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
// When format is "pretty", outputs human-readable text; otherwise JSON.
func PrintDryRun(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions) error {
dr := buildDryRunPreview(request, config)
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
if !util.IsNil(request.Data) {
dr.Body(request.Data)
}
return WriteDryRun(dr, opts)
}
// WriteDryRun emits a DryRunAPI using the shared dry-run output contract.
// Identity may be empty; the envelope omits it rather than guessing.
func WriteDryRun(dr *DryRunAPI, opts DryRunOutputOptions) error {
if dr == nil {
return errs.NewInternalError(errs.SubtypeUnknown, "dry-run produced no request preview")
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
// The JqExpr guard is defensive: every entry point already rejects --jq
// combined with --format pretty via output.ValidateJqFlags.
if opts.Format == "pretty" && opts.JqExpr == "" {
// A nil ErrOut only skips the banner decoration (mirroring
// WriteSuccessEnvelope's warning path); the payload write to Out
// must fail loudly rather than be silently discarded.
if opts.ErrOut != nil {
fmt.Fprintln(opts.ErrOut, "=== Dry Run ===")
}
// stdout carries its own marker so logs that drop stderr still show
// this was a preview, not an executed request.
fmt.Fprintln(opts.Out, "# dry-run: request not sent")
fmt.Fprint(opts.Out, dr.Format())
return nil
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return output.WriteSuccessEnvelope(dr, output.SuccessEnvelopeOptions{
CommandPath: opts.CommandPath,
Identity: string(opts.Identity),
DryRun: true,
JqExpr: opts.JqExpr,
Out: opts.Out,
ErrOut: opts.ErrOut,
})
return nil
}

View File

@@ -6,12 +6,9 @@ package cmdutil
import (
"bytes"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
@@ -69,31 +66,11 @@ func TestDryRunAPI_ResolveURL(t *testing.T) {
}
}
func TestDryRunAPI_ResolveURLMatchesFullPlaceholderOnly(t *testing.T) {
dr := NewDryRunAPI().
GET("/open-apis/task/v2/tasks/:assignee_id").
Set("assignee", "ou_bot")
text := dr.Format()
if strings.Contains(text, "ou_bot_id") {
t.Fatalf("prefix placeholder key corrupted longer token: %s", text)
}
if !strings.Contains(text, ":assignee_id") {
t.Fatalf("missing unresolved placeholder, got: %s", text)
}
dr.Set("assignee_id", "ou_abc/123")
text = dr.Format()
if !strings.Contains(text, "/open-apis/task/v2/tasks/ou_abc%2F123") {
t.Fatalf("expected full placeholder replacement with path escaping, got: %s", text)
}
}
func TestDryRunAPI_MarshalJSON(t *testing.T) {
dr := NewDryRunAPI().
Desc("test api").
GET("/open-apis/test").
Set("note", "audit")
Set("as", "user")
data, err := json.Marshal(dr)
if err != nil {
@@ -106,8 +83,8 @@ func TestDryRunAPI_MarshalJSON(t *testing.T) {
if m["description"] != "test api" {
t.Errorf("expected description, got: %v", m["description"])
}
if m["note"] != "audit" {
t.Errorf("expected note=audit, got: %v", m["note"])
if m["as"] != "user" {
t.Errorf("expected as=user, got: %v", m["as"])
}
api, ok := m["api"].([]interface{})
if !ok || len(api) != 1 {
@@ -146,67 +123,31 @@ func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) {
func TestPrintDryRun_JSON(t *testing.T) {
var buf bytes.Buffer
var errBuf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
err := PrintDryRun(&buf, client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "user",
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
Format: "json",
CommandPath: "lark-cli api",
Identity: core.AsUser,
Out: &buf,
ErrOut: &errBuf,
})
}, &core.CliConfig{AppID: "app123"}, "json")
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
out := buf.String()
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("JSON stdout must not contain banner, got: %s", out)
if !strings.Contains(out, "=== Dry Run ===") {
t.Errorf("expected header, got: %s", out)
}
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
}
if env["ok"] != true || env["identity"] != "user" || env["dry_run"] != true {
t.Fatalf("unexpected envelope: %#v", env)
}
data, ok := env["data"].(map[string]interface{})
if !ok {
t.Fatalf("unexpected data: %#v", env["data"])
}
dctx, ok := data["context"].(map[string]interface{})
if !ok || dctx["app_id"] != "app123" {
t.Fatalf("unexpected data.context: %#v", data["context"])
}
if _, exists := data["as"]; exists {
t.Fatalf("data.as must not appear; identity lives at the envelope top level: %#v", data)
}
api, ok := data["api"].([]interface{})
if !ok || len(api) != 1 {
t.Fatalf("api = %#v, want one call", data["api"])
}
call, ok := api[0].(map[string]interface{})
if !ok || call["url"] != "/open-apis/test" {
t.Fatalf("api[0] = %#v", api[0])
if !strings.Contains(out, "app123") {
t.Errorf("expected appId in output, got: %s", out)
}
}
func TestPrintDryRun_Pretty(t *testing.T) {
var buf bytes.Buffer
var errBuf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
err := PrintDryRun(&buf, client.RawApiRequest{
Method: "POST",
URL: "/open-apis/test",
Data: map[string]interface{}{"key": "val"},
As: "bot",
}, &core.CliConfig{AppID: "app456"}, DryRunOutputOptions{
Format: "pretty",
Identity: core.AsBot,
Out: &buf,
ErrOut: &errBuf,
})
}, &core.CliConfig{AppID: "app456"}, "pretty")
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
@@ -214,136 +155,6 @@ func TestPrintDryRun_Pretty(t *testing.T) {
if !strings.Contains(out, "POST /open-apis/test") {
t.Errorf("expected POST line in pretty output, got: %s", out)
}
if !strings.HasPrefix(out, "# dry-run: request not sent\n") {
t.Fatalf("pretty stdout should start with the dry-run marker, got: %s", out)
}
if strings.Contains(out, "=== Dry Run ===") {
t.Fatalf("pretty stdout must not contain banner, got: %s", out)
}
if !strings.Contains(errBuf.String(), "=== Dry Run ===") {
t.Fatalf("pretty stderr should contain banner, got: %s", errBuf.String())
}
}
func TestPrintDryRun_WithJqUsesEnvelope(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "bot",
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
Format: "json",
JqExpr: ".data.api[0].url",
Identity: core.AsBot,
Out: &buf,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "/open-apis/test" {
t.Fatalf("jq output = %q, want /open-apis/test", got)
}
}
func TestPrintDryRunWithFile_JSONEnvelope(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRunWithFile(client.RawApiRequest{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
As: "bot",
}, &core.CliConfig{AppID: "app123", UserOpenId: "ou_tester"}, DryRunOutputOptions{
Format: "json",
Identity: core.AsBot,
Out: &buf,
ErrOut: io.Discard,
}, FileUploadMeta{FieldName: "file", FilePath: "report.txt", FormFields: map[string]any{"parent": "fld"}})
if err != nil {
t.Fatalf("PrintDryRunWithFile failed: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
}
if env["dry_run"] != true {
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
}
data := env["data"].(map[string]interface{})
api := data["api"].([]interface{})
call := api[0].(map[string]interface{})
body := call["body"].(map[string]interface{})
file := body["file"].(map[string]interface{})
if file["path"] != "report.txt" {
t.Fatalf("file body = %#v", body)
}
dctx, ok := data["context"].(map[string]interface{})
if !ok || dctx["app_id"] != "app123" || dctx["user_open_id"] != "ou_tester" {
t.Fatalf("unexpected data.context: %#v", data["context"])
}
for _, legacy := range []string{"as", "appId", "userOpenId"} {
if _, exists := data[legacy]; exists {
t.Fatalf("legacy key %q must not appear in data: %#v", legacy, data)
}
}
}
func TestPrintDryRun_MethodTranscribedVerbatim(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "OPTIONS",
URL: "/open-apis/test",
As: "bot",
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
Format: "json",
Identity: core.AsBot,
Out: &buf,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
}
call := env["data"].(map[string]interface{})["api"].([]interface{})[0].(map[string]interface{})
if call["method"] != "OPTIONS" {
t.Fatalf("method = %#v, want OPTIONS transcribed verbatim (not coerced to GET)", call["method"])
}
}
func TestPrintDryRun_EmptyConfigOmitsContext(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
}, &core.CliConfig{}, DryRunOutputOptions{
Format: "json",
Out: &buf,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
}
data := env["data"].(map[string]interface{})
if _, exists := data["context"]; exists {
t.Fatalf("empty app/user context must be omitted entirely, got: %#v", data["context"])
}
}
func TestWriteDryRun_NilPreviewIsInternalError(t *testing.T) {
err := WriteDryRun(nil, DryRunOutputOptions{Format: "json", Out: io.Discard})
if err == nil {
t.Fatal("WriteDryRun(nil) should fail instead of emitting an empty preview")
}
var internal *errs.InternalError
if !errors.As(err, &internal) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
}
func TestDryRunFormatValue(t *testing.T) {

View File

@@ -22,7 +22,6 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
@@ -34,7 +33,7 @@ import (
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
// Phase 4: LarkClient derived from Credential
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
@@ -55,10 +54,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
f.HttpClient = cachedHttpClientFunc(f)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
@@ -69,7 +67,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Runtime config contains resolved account data only.
// Phase 3: Config derived from Credential via an explicit conversion boundary.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -80,9 +78,8 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
return f
}
@@ -111,16 +108,13 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var rt http.RoundTripper = transport.Shared()
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
@@ -134,7 +128,7 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
})
}
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -148,15 +142,8 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: sdkTransport,
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -165,8 +152,9 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
})
}
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}

View File

@@ -6,15 +6,10 @@ package cmdutil
import (
"io"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c1, err := fn()
if err != nil {
@@ -34,10 +29,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -45,10 +37,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -8,7 +8,6 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -37,15 +36,13 @@ var proxyWarnGateCases = []struct {
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
f.IOStreams.StderrIsTerminal = tc.terminal
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -76,7 +73,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
if _, err := cachedLarkClientFunc(f)(); err != nil {
t.Fatalf("lark client init: %v", err)
}

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io/fs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// StatLocalFile returns metadata for a path in the process filesystem namespace.
// It is intended for advisory validation; callers must validate the opened file
// again before using its contents.
func StatLocalFile(path string) (fs.FileInfo, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(localPath)
}
// OpenLocalFile opens a path in the process filesystem namespace.
// Absolute and relative paths are accepted. It is the shared replacement for
// direct os.Open/os.ReadFile use in commands that intentionally read local
// paths outside the workspace sandbox. Callers inspect the returned descriptor
// before reading so validation and use apply to the same opened file.
func OpenLocalFile(path string) (fs.File, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(localPath)
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
TestChdir(t, workDir)
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
f, err := OpenLocalFile(input)
if err != nil {
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
}
got, readErr := io.ReadAll(f)
closeErr := f.Close()
if readErr != nil || closeErr != nil || string(got) != "content" {
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
}
}
}
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
}
}
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
info, err := StatLocalFile(t.TempDir())
if err != nil {
t.Fatalf("StatLocalFile() error = %v", err)
}
if !info.IsDir() {
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
}
}
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
previous := vfs.DefaultFS
counting := &countingLocalFileFS{FS: previous}
vfs.DefaultFS = counting
t.Cleanup(func() { vfs.DefaultFS = previous })
f, err := OpenLocalFile(path)
if err != nil {
t.Fatalf("OpenLocalFile() error = %v", err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if counting.openCalls != 1 || counting.statCalls != 0 {
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
}
}
type countingLocalFileFS struct {
vfs.FS
openCalls int
statCalls int
}
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
f.openCalls++
return f.FS.Open(name)
}
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
f.statCalls++
return f.FS.Stat(name)
}

View File

@@ -77,11 +77,6 @@ func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, er
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
// All paths go through the caller's fileIO provider and its relative-to-cwd
// policy — no absolute-path side door: a trust root defined by the process
// environment (TMPDIR) is not a security boundary, and reading outside the
// provider would break sidecar/custom-FileIO ownership. Out-of-tree content
// reaches flags via stdin ("-").
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
if fileIO == nil {
return nil, fmt.Errorf("file input is not available in this context")

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/riskcontrol"
)
type workspaceConfigSource interface {
MultiAppConfig() (*core.MultiAppConfig, error)
}
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
// boundary.
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
if config == nil {
return nil
}
workspace, configErr := config.MultiAppConfig()
// Default-on means an existing config with no explicit preference. Absent
// or unreadable config cannot authorize host-signal collection.
if configErr != nil || !workspace.RiskControlEnabled() {
return nil
}
return riskcontrol.NewHostSource()
}

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
)
type staticWorkspaceConfig struct {
config *core.MultiAppConfig
err error
}
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
return s.config, s.err
}
func TestResolveSDKHostSignalSource(t *testing.T) {
disabled := false
tests := []struct {
name string
config workspaceConfigSource
wantSource bool
}{
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
{name: "nil config value", config: staticWorkspaceConfig{}},
{name: "nil config source"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := resolveSDKHostSignalSource(test.config)
if (got != nil) != test.wantSource {
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
}
})
}
}

View File

@@ -26,7 +26,6 @@ const (
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
HeaderAgentName = "X-Agent-Name"
SourceValue = "lark-cli"
@@ -56,9 +55,6 @@ func BaseSecurityHeaders() http.Header {
if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v)
}
if v := envvars.AgentName(); v != "" {
h.Set(HeaderAgentName, v)
}
return h
}

View File

@@ -263,34 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// Agent headers injected via BaseSecurityHeaders
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// ---------------------------------------------------------------------------
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentName, "")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
const agentName = "sample-agent"
t.Setenv(envvars.CliAgentName, agentName)
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != agentName {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
}
}
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()

View File

@@ -1,30 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
// Default-factory tests initialize the registry and resolve config. Keep
// them deterministic: never read the developer's real ~/.lark-cli and
// prevent background remote-metadata refreshes from touching user state.
root, err := os.MkdirTemp("", "lark-cli-cmdutil-test-*")
if err != nil {
println("internal/cmdutil test setup: MkdirTemp failed:", err.Error())
os.Exit(2)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -15,7 +15,6 @@ import (
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -92,13 +91,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
// ---------------------------------------------------------------------------
// wrapSDKTransport chain composition
// buildSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -111,23 +110,18 @@ func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
retry, ok := ua.Base.(*RetryTransport)
if !ok {
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestWrapSDKTransport_WithExtension(t *testing.T) {
previous := exttransport.GetProvider()
func TestBuildSDKTransport_WithExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(previous) })
t.Cleanup(func() { exttransport.Register(nil) })
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
transport := buildSDKTransport()
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -144,23 +138,17 @@ func TestWrapSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
retry, ok := ua.Base.(*RetryTransport)
if !ok {
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
transport := buildSDKTransport()
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -173,13 +161,9 @@ func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
retry, ok := ua.Base.(*RetryTransport)
if !ok {
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
// ---------------------------------------------------------------------------
@@ -277,40 +261,6 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
return nil
}
type riskHeaderTamperingInterceptor struct{}
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
return nil
}
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
t.Fatalf("extension risk headers reached network: %v", received)
}
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
@@ -327,7 +277,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by wrapSDKTransport.
// Replicate the SDK chain layering used by buildSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}

View File

@@ -60,18 +60,11 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// RiskControlEnabled resolves the workspace policy. An omitted preference
// keeps the default-on account-protection behavior.
func (m *MultiAppConfig) RiskControlEnabled() bool {
return m != nil && (m.RiskControl == nil || *m.RiskControl)
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"io/fs"
"sync"
)
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
// invocation. All runtime consumers share the same load result so account and
// workspace policy resolution cannot observe different file revisions. Callers
// must treat the returned config as read-only.
type ConfigSnapshot struct {
load func() (*MultiAppConfig, error)
}
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}
}
return &ConfigSnapshot{load: sync.OnceValues(load)}
}
// MultiAppConfig returns the captured persistent config and load error.
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
if s == nil || s.load == nil {
return nil, fs.ErrNotExist
}
return s.load()
}

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"io/fs"
"testing"
)
func TestConfigSnapshotLoadsOnce(t *testing.T) {
calls := 0
want := &MultiAppConfig{}
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return want, nil
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if config != want {
t.Fatal("snapshot returned a different config instance")
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
config, err := (&ConfigSnapshot{}).MultiAppConfig()
if config != nil || !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return nil, want
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if config != nil || !errors.Is(err, want) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}

View File

@@ -60,9 +60,7 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
disabled := false
config := &MultiAppConfig{
RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -86,9 +84,6 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
if got.RiskControl == nil || *got.RiskControl {
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {

View File

@@ -16,18 +16,16 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
const agentName = "sample-agent"
t.Setenv(CliAgentName, agentName)
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q", got, agentName)
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
const agentName = "sample-agent"
t.Setenv(CliAgentName, " "+agentName+" ")
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
}
}

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// sparkCodeMeta holds stable Spark app-role business-code classifications.
// Command-specific recovery guidance belongs in the Apps shortcut layer; the
// numeric code remains the source-specific discriminator on the error envelope.
var sparkCodeMeta = map[int]CodeMeta{
3340001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request parameters are invalid
3344027: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role user count exceeds the service limit
3344028: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role department count exceeds the service limit
3344029: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role chat count exceeds the service limit
3344030: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator required
3344031: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator or developer required
3344034: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role ID
3344035: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // role does not exist
3344036: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // role ID already exists
3344037: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // app role count exceeds the service limit
3344038: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role name
3344039: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role description
3344040: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // unsupported member type
3344041: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid member ID
}
func init() { mergeCodeMeta(sparkCodeMeta, "spark") }

View File

@@ -1,59 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestLookupCodeMetaSparkRoleCodes(t *testing.T) {
tests := []struct {
code int
category errs.Category
subtype errs.Subtype
}{
{3340001, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344027, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344028, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344029, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344030, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
{3344031, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
{3344034, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344035, errs.CategoryAPI, errs.SubtypeNotFound},
{3344036, errs.CategoryAPI, errs.SubtypeAlreadyExists},
{3344037, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344038, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344039, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344040, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344041, errs.CategoryAPI, errs.SubtypeInvalidParameters},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%d", tt.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tt.code)
if !ok {
t.Fatalf("code %d is not registered", tt.code)
}
if meta.Category != tt.category || meta.Subtype != tt.subtype || meta.Retryable {
t.Fatalf("code %d metadata = %+v, want category=%s subtype=%s retryable=false", tt.code, meta, tt.category, tt.subtype)
}
err := BuildAPIError(map[string]any{
"code": tt.code,
"msg": "spark role error",
"log_id": "log-spark-role",
}, ClassifyContext{Identity: "user"})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("BuildAPIError(%d) = %#v, want typed problem", tt.code, err)
}
if problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Code != tt.code || problem.LogID != "log-spark-role" || problem.Retryable {
t.Fatalf("BuildAPIError(%d) problem = %+v", tt.code, problem)
}
})
}
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-event-test-*")
if err != nil {
panic(err)
}
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
panic(err)
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -38,10 +38,6 @@ type Stub struct {
// matches after the first hit. Each match appends to CapturedBodies.
Reusable bool
// Optional (optional): when true, Verify does not require this stub to be
// matched. Useful for negative assertions via OnMatch.
Optional bool
// CapturedHeaders records the request headers of the matched request.
// Populated after RoundTrip matches this stub.
CapturedHeaders http.Header
@@ -141,9 +137,6 @@ func (r *Registry) Verify(t testing.TB) {
if s.matched {
continue
}
if s.Optional {
continue
}
// Reusable stubs never set s.matched; treat any captured hit as a match.
if s.Reusable && len(s.CapturedBodies) > 0 {
continue

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keychain
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-keychain-test-*")
if err != nil {
panic(err)
}
for key, value := range map[string]string{
"LARKSUITE_CLI_DATA_DIR": filepath.Join(root, "data"),
"LARKSUITE_CLI_LOG_DIR": filepath.Join(root, "logs"),
} {
if err := os.Setenv(key, value); err != nil {
panic(err)
}
}
code := m.Run()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -7,91 +7,70 @@ import (
"encoding/csv"
"fmt"
"io"
"os"
)
// FormatAsCSV formats data as CSV (with header) and writes it to w.
func FormatAsCSV(w io.Writer, data interface{}) {
// Match the other legacy wrappers: surface only a marshal failure (as the
// JSON fallback historically did); plain write failures stay swallowed.
if err := WriteCSV(w, data); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteCSV formats data as CSV and returns marshal or write errors.
func WriteCSV(w io.Writer, data interface{}) error {
return WriteCSVPaginated(w, data, true)
FormatAsCSVPaginated(w, data, true)
}
// FormatAsCSVPaginated formats data as CSV with pagination awareness.
// When isFirstPage is true, outputs the header row; otherwise only data rows.
func FormatAsCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) {
if err := WriteCSVPaginated(w, data, isFirstPage); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteCSVPaginated formats data as CSV and returns marshal or write errors.
func WriteCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) error {
rows, cols, isList := prepareRows(data)
if cols == nil {
if isList {
_, err := fmt.Fprintln(w, "(empty)")
return err
fmt.Fprintln(w, "(empty)")
} else {
return WriteJSON(w, data)
PrintJson(w, data)
}
return
}
if len(rows) == 0 {
if isFirstPage {
_, err := fmt.Fprintln(w, "(empty)")
return err
fmt.Fprintln(w, "(empty)")
}
return nil
return
}
if !isList {
// Single object: key,value rows
cw := csv.NewWriter(w)
if isFirstPage {
if err := cw.Write([]string{"key", "value"}); err != nil {
return err
}
cw.Write([]string{"key", "value"})
}
for _, col := range cols {
if err := cw.Write([]string{col, rows[0][col]}); err != nil {
return err
}
cw.Write([]string{col, rows[0][col]})
}
return flushCSV(cw)
flushCSV(cw)
return
}
return writeCSVRows(w, rows, cols, isFirstPage)
writeCSVRows(w, rows, cols, isFirstPage)
}
// writeCSVRows writes CSV data rows (and optionally header) using the given columns.
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) error {
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) {
cw := csv.NewWriter(w)
if writeHeader {
if err := cw.Write(cols); err != nil {
return err
}
cw.Write(cols)
}
for _, row := range rows {
record := make([]string, len(cols))
for i, col := range cols {
record[i] = row[col]
}
if err := cw.Write(record); err != nil {
return err
}
cw.Write(record)
}
return flushCSV(cw)
flushCSV(cw)
}
// flushCSV flushes the csv.Writer and returns any write error.
func flushCSV(cw *csv.Writer) error {
// flushCSV flushes the csv.Writer and reports any write error to stderr.
func flushCSV(cw *csv.Writer) {
cw.Flush()
return cw.Error()
if err := cw.Error(); err != nil {
fmt.Fprintf(os.Stderr, "csv write error: %v\n", err)
}
}

View File

@@ -50,11 +50,10 @@ func wrapBlockError(alert *extcs.Alert) error {
// WriteAlertWarning writes a human-readable content-safety warning to w.
// Used by non-JSON output paths (pretty, table, csv) in warn mode.
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) {
if alert == nil {
return nil
return
}
_, err := fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
alert.Provider, strings.Join(alert.MatchedRules, ", "))
return err
}

View File

@@ -1,336 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"encoding/json"
"fmt"
"io"
"maps"
"github.com/larksuite/cli/errs"
)
// NoticeProvider supplies the notice attached to a structured envelope.
// The provider is captured by an Emitter so emission never reads the global
// PendingNotice hook implicitly.
type NoticeProvider func() map[string]interface{}
// PrettyRenderer writes the human-readable representation of one result.
// colorEnabled is the terminal capability captured when the Emitter is built.
type PrettyRenderer func(w io.Writer, colorEnabled bool) error
// EmitterConfig contains command-scoped dependencies. A command constructs one
// Emitter and reuses it for its success result or streamed pages.
type EmitterConfig struct {
Out io.Writer
ErrOut io.Writer
CommandPath string
Identity string
ColorEnabled bool
NoticeProvider NoticeProvider
}
// EmitOptions describes one result's wire representation.
//
// The format contract is explicit: JSON (including the empty default) uses an
// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes
// precedence over Format and filters the JSON Envelope. Raw affects only JSON
// envelope encoding and jq's complex-value encoding.
//
// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit
// (false) and WriteSuccessEnvelope (true) until their callers are migrated.
type EmitOptions struct {
Raw bool
Meta *Meta
Format string
JQ string
DryRun bool
Pretty PrettyRenderer
JQSafetyWarning bool
}
// StreamOptions describes one streamed page's wire representation. Streaming
// carries page items directly, so it deliberately exposes only the fields that
// affect a single page: the format and, for pretty, its renderer. It has no
// OK/Meta/DryRun/JQ — an ok:false envelope, metadata, dry-run, and jq all need
// the aggregated result, which the caller's pagination layer owns before it
// streams pages.
type StreamOptions struct {
Format string
Pretty PrettyRenderer
}
// Emitter owns all command-scoped output dependencies and pagination state.
// It deliberately has no dependency on client or cmdutil.
type Emitter struct {
out io.Writer
errOut io.Writer
commandPath string
identity string
colorEnabled bool
noticeProvider NoticeProvider
streamFormat string
streamFormatter *PaginatedFormatter
}
// NewEmitter constructs a command-scoped output emitter.
func NewEmitter(config EmitterConfig) *Emitter {
errOut := config.ErrOut
if errOut == nil {
errOut = io.Discard
}
return &Emitter{
out: config.Out,
errOut: errOut,
commandPath: config.CommandPath,
identity: config.Identity,
colorEnabled: config.ColorEnabled,
noticeProvider: config.NoticeProvider,
}
}
// Success scans and emits one command result by composing the package's leaf
// primitives. JSON and jq use the standard envelope; pretty, table, csv, and
// ndjson render the business value directly.
func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
if err := e.requireOutput(); err != nil {
return err
}
if opts.JQ != "" {
return e.emitEnvelope(data, true, opts)
}
switch opts.Format {
case "", "json":
return e.emitEnvelope(data, true, opts)
case "pretty":
return e.emitPretty(data, opts)
default:
return e.emitFormatted(data, opts.Format)
}
}
// PartialFailure emits a multi-status result whose envelope honestly reports
// ok:false. It is the typed counterpart to Success for batch operations where
// some items failed but the per-item outcomes are the primary stdout output.
// Like the legacy OutPartialFailure it produces only the JSON/jq envelope; the
// caller owns the non-zero exit signal, keeping the Emitter free of exit
// semantics.
func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
if err := e.requireOutput(); err != nil {
return err
}
return e.emitEnvelope(data, false, opts)
}
// StreamPage scans and emits one page while retaining table/csv columns from
// the first page. Streamed output carries page items directly, so it takes a
// StreamOptions (format + optional pretty renderer) rather than the full
// EmitOptions: ok/meta/dry-run/jq all need the aggregated result and are the
// caller's pagination-layer responsibility, not a per-page concern. Excluding
// jq from the type makes "jq requires aggregated output" a compile-time fact
// instead of a runtime rejection.
func (e *Emitter) StreamPage(data interface{}, opts StreamOptions) error {
if err := e.requireOutput(); err != nil {
return err
}
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if opts.Format == "pretty" {
if opts.Pretty == nil {
return errs.NewInternalError(errs.SubtypeUnknown,
"pretty output requires a renderer")
}
return e.emit(func(w io.Writer) error {
return opts.Pretty(w, e.colorEnabled)
})
}
format, known := ParseFormat(opts.Format)
if !known && e.streamFormatter == nil && e.errOut != nil {
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
if e.streamFormatter == nil {
e.streamFormat = opts.Format
e.streamFormatter = NewPaginatedFormatter(nil, format)
} else if opts.Format != e.streamFormat {
return errs.NewInternalError(errs.SubtypeUnknown,
"stream output format changed from %q to %q", e.streamFormat, opts.Format)
}
return e.emit(func(w io.Writer) error {
e.streamFormatter.W = w
return e.streamFormatter.WritePage(data)
})
}
func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
env := Envelope{
OK: ok,
Identity: e.identity,
DryRun: opts.DryRun,
Data: data,
Meta: opts.Meta,
Notice: e.notice(),
}
if scanResult.Alert != nil {
env.ContentSafetyAlert = scanResult.Alert
}
if opts.JQ != "" {
if scanResult.Alert != nil && opts.JQSafetyWarning {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
// Buffer the jq output manually so jq's own typed error (a validation
// error for a bad expression, an api error for a runtime failure) is
// returned unchanged; only a genuine stdout write failure is wrapped as
// an internal output error.
var buf bytes.Buffer
var jqErr error
if opts.Raw {
jqErr = JqFilterRaw(&buf, env, opts.JQ)
} else {
jqErr = JqFilter(&buf, env, opts.JQ)
}
if jqErr != nil {
return jqErr
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
return e.emit(func(w io.Writer) error {
if opts.Raw {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(env)
}
return WriteJSON(w, env)
})
}
func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if opts.Pretty != nil {
return e.emit(func(w io.Writer) error {
return opts.Pretty(w, e.colorEnabled)
})
}
// RuntimeContext.outFormat falls back through Out/OutRaw when no pretty
// renderer is supplied. Keep that second scan visible in the leaf contract
// until production callers are migrated and the legacy behavior is removed.
return e.emitEnvelope(data, true, opts)
}
func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
format, known := ParseFormat(rawFormat)
if !known && e.errOut != nil {
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat)
}
if format == FormatJSON {
return e.printLegacyDataJSON(data)
}
return e.emit(func(w io.Writer) error {
return WriteFormatted(w, data, format)
})
}
type emitterDataMap map[string]interface{}
// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice
// data from this Emitter instead of PrintJson's global PendingNotice hook.
func (e *Emitter) printLegacyDataJSON(data interface{}) error {
// Normalise structs / named maps to plain generic types first, exactly as
// FormatValue does, so a struct or named-map payload still matches the map
// case below and keeps its injected _notice on the unknown-format fallback.
data = toGeneric(data)
if m, ok := data.(map[string]interface{}); ok {
if _, isEnvelope := m["ok"]; isEnvelope {
if notice := e.notice(); notice != nil {
m = maps.Clone(m)
m["_notice"] = notice
}
}
// The named map retains identical JSON bytes while preventing PrintJson
// from consulting its legacy global notice hook a second time.
return e.emit(func(w io.Writer) error {
return WriteJSON(w, emitterDataMap(m))
})
}
return e.emit(func(w io.Writer) error {
return WriteJSON(w, data)
})
}
func (e *Emitter) emit(render func(io.Writer) error) error {
var buf bytes.Buffer
if err := render(&buf); err != nil {
return wrapOutputError("render", err)
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
func wrapOutputError(op string, err error) error {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s command output", op).WithCause(err)
}
func (e *Emitter) notice() map[string]interface{} {
if e.noticeProvider == nil {
return nil
}
return e.noticeProvider()
}
func (e *Emitter) requireOutput() error {
if e == nil || e.out == nil {
return errs.NewInternalError(errs.SubtypeUnknown,
"success output writer is not configured")
}
return nil
}

View File

@@ -1,350 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/output"
)
type contractFailingWriter struct {
err error
}
func (w contractFailingWriter) Write([]byte) (int, error) {
return 0, w.err
}
type contractSafetyProvider struct {
alert *extcs.Alert
}
func (p *contractSafetyProvider) Name() string {
return "emitter-contract"
}
func (p *contractSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
return p.alert, nil
}
func TestEmitterSuccessWritesAllBytes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
})
data := map[string]interface{}{"id": "1"}
err := emitter.Success(data, output.EmitOptions{Format: "json"})
if err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
want, marshalErr := json.MarshalIndent(output.Envelope{OK: true, Identity: "bot", Data: data}, "", " ")
if marshalErr != nil {
t.Fatalf("marshal expected envelope: %v", marshalErr)
}
want = append(want, '\n')
if !bytes.Equal(stdout.Bytes(), want) {
t.Fatalf("stdout bytes = %q, want %q", stdout.Bytes(), want)
}
}
func TestEmitterMarshalFailureReturnsTypedErrorWithoutOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"unsupported": func() {}}, output.EmitOptions{Format: "json"})
if err == nil {
t.Fatal("Emitter.Success() error = nil, want marshal failure")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
var unsupported *json.UnsupportedTypeError
if !errors.As(err, &unsupported) {
t.Fatalf("Emitter.Success() error = %v, want json.UnsupportedTypeError cause", err)
}
if stdout.Len() != 0 {
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
}
}
func TestEmitterWriterFailurePreservesCause(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
sentinel := errors.New("write failed")
emitter := output.NewEmitter(output.EmitterConfig{
Out: contractFailingWriter{err: sentinel},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
}
func TestEmitterPrettyRendererFailurePreservesCause(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
sentinel := errors.New("pretty render failed")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Format: "pretty",
Pretty: func(io.Writer, bool) error {
return sentinel
},
})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved renderer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
if stdout.Len() != 0 {
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
}
}
func TestEmitterAlertWarningFailurePreservesCause(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&contractSafetyProvider{alert: &extcs.Alert{
Provider: "emitter-contract",
MatchedRules: []string{"fixture-rule"},
}})
t.Cleanup(func() { extcs.Register(nil) })
sentinel := errors.New("warning write failed")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: contractFailingWriter{err: sentinel},
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success([]interface{}{map[string]interface{}{"id": "1"}}, output.EmitOptions{Format: "table"})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved warning writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
if stdout.Len() != 0 {
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
}
}
func TestNewEmitterDefaultsNilErrOutToDiscard(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&contractSafetyProvider{alert: &extcs.Alert{
Provider: "emitter-contract",
MatchedRules: []string{"fixture-rule"},
}})
t.Cleanup(func() { extcs.Register(nil) })
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
CommandPath: "lark-cli fixture +emit",
})
if err := emitter.Success([]interface{}{map[string]interface{}{"id": "1"}}, output.EmitOptions{Format: "table"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if stdout.Len() == 0 {
t.Fatal("Emitter.Success() stdout is empty")
}
}
func TestEmitterDoesNotMutateCallerMap(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
data := map[string]interface{}{"ok": true, "value": "fixture"}
want := map[string]interface{}{"ok": true, "value": "fixture"}
emitter := output.NewEmitter(output.EmitterConfig{
Out: &bytes.Buffer{},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
NoticeProvider: func() map[string]interface{} {
return map[string]interface{}{"update": "available"}
},
})
if err := emitter.Success(data, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if !reflect.DeepEqual(data, want) {
t.Fatalf("caller map = %#v, want unchanged %#v", data, want)
}
}
func TestEmitterDoesNotOverwriteCallerNotice(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
existing := map[string]interface{}{"source": "caller"}
data := map[string]interface{}{"ok": true, "_notice": existing}
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
NoticeProvider: func() map[string]interface{} {
return map[string]interface{}{"source": "provider"}
},
})
if err := emitter.Success(data, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if got := data["_notice"]; !reflect.DeepEqual(got, existing) {
t.Fatalf("caller _notice = %#v, want unchanged %#v", got, existing)
}
var emitted map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &emitted); err != nil {
t.Fatalf("decode stdout: %v", err)
}
if got := emitted["_notice"]; !reflect.DeepEqual(got, map[string]interface{}{"source": "provider"}) {
t.Fatalf("emitted _notice = %#v, want provider notice", got)
}
}
func TestEmitterReadsNoticeProviderAtMostOncePerEmission(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
calls := 0
emitter := output.NewEmitter(output.EmitterConfig{
Out: &bytes.Buffer{},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
NoticeProvider: func() map[string]interface{} {
calls++
return map[string]interface{}{"source": "provider"}
},
})
if err := emitter.Success(map[string]interface{}{"ok": true}, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if calls != 1 {
t.Fatalf("notice provider calls = %d, want 1", calls)
}
}
func TestEmitterRawJSONPropagatesWriteError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
sentinel := errors.New("write failed")
emitter := output.NewEmitter(output.EmitterConfig{
Out: contractFailingWriter{err: sentinel},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Raw: true, Format: "json",
})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
}
func TestEmitterInvalidJQReturnsErrorWithoutStderr(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stderr := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: &bytes.Buffer{},
ErrOut: stderr,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Format: "json",
JQ: "this is not valid jq (((",
})
if err == nil {
t.Fatal("Success() with invalid jq = nil, want error")
}
if stderr.Len() != 0 {
t.Fatalf("Success() with invalid jq wrote stderr %q, want empty", stderr.String())
}
}
func TestEmitterJQRuntimeErrorPreservesTypedError(t *testing.T) {
// A valid expression that fails at runtime must surface jq's own typed error
// (an api error), not a wrapped internal output error, and must emit no
// partial stdout.
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Format: "json",
JQ: `error("boom")`,
})
if err == nil {
t.Fatal("Success() with a runtime jq error = nil, want error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category == errs.CategoryInternal {
t.Fatalf("Success() jq runtime error problem = %#v, %v; want jq's own typed error, not internal", problem, ok)
}
if !strings.Contains(err.Error(), "jq error") {
t.Fatalf("Success() jq runtime error = %v, want jq's own error message preserved", err)
}
if stdout.Len() != 0 {
t.Fatalf("Success() jq runtime error wrote stdout %q, want empty", stdout.String())
}
}
func TestEmitterUnknownFormatStructKeepsNotice(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
type payload struct {
OK bool `json:"ok"`
Value string `json:"value"`
}
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
NoticeProvider: func() map[string]interface{} {
return map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}}
},
})
if err := emitter.Success(payload{OK: true, Value: "fixture"}, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Success() error = %v", err)
}
if !strings.Contains(stdout.String(), "_notice") {
t.Fatalf("struct payload on unknown-format fallback dropped _notice:\n%s", stdout.String())
}
}

View File

@@ -1,827 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Legacy oracle fixtures are frozen at base SHA 4a56748bfa941ff0ee0bfec92e65acac427732b0.
// Golden regeneration is allowed only from that base, never from the current system under test.
package output_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
type emitterCapture struct {
stdout string
stderr string
err error
}
type emitterSafetyProvider struct {
alert *extcs.Alert
err error
}
func (p *emitterSafetyProvider) Name() string { return "emitter-oracle" }
func (p *emitterSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
return p.alert, p.err
}
const (
runtimeContextLegacyGoldenPath = "testdata/runtime_context_legacy.golden.json"
writeSuccessEnvelopeLegacyGoldenPath = "testdata/write_success_envelope_legacy.golden.json"
)
type runtimeContextOracleCase struct {
name string
data func() interface{}
raw bool
ok bool
meta *output.Meta
jq string
format string
useFormat bool
pretty bool
notice map[string]interface{}
safetyMode string
safetyAlert *extcs.Alert
safetyErr error
}
type runtimeContextLegacyGolden struct {
Cases map[string]emitterCaptureGolden `json:"cases"`
}
type writeSuccessEnvelopeOracleCase struct {
name string
data func() interface{}
dryRun bool
jq string
notice map[string]interface{}
safetyMode string
safetyAlert *extcs.Alert
}
type writeSuccessEnvelopeLegacyGolden struct {
Cases map[string]emitterCaptureGolden `json:"cases"`
}
type emitterCaptureGolden struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Error *emitterErrorGolden `json:"error,omitempty"`
}
type emitterErrorGolden struct {
GoType string `json:"go_type"`
JSON json.RawMessage `json:"json"`
Message string `json:"message"`
ExitCode int `json:"exit_code"`
}
func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
previousNotice := output.PendingNotice
t.Cleanup(func() {
output.PendingNotice = previousNotice
extcs.Register(nil)
})
cases := []runtimeContextOracleCase{
{
name: "json_object",
data: func() interface{} {
return map[string]interface{}{"id": "1", "enabled": true}
},
ok: true,
},
{
name: "raw_json_preserves_html",
data: func() interface{} {
return map[string]interface{}{"html": "<p>a&b</p>"}
},
raw: true,
ok: true,
},
{
name: "format_raw_json_preserves_html",
data: func() interface{} {
return map[string]interface{}{"html": "<p>a&b</p>"}
},
raw: true,
ok: true,
format: "json",
useFormat: true,
},
{
name: "partial_failure_ok_false",
data: func() interface{} {
return map[string]interface{}{"succeeded": 1, "failed": 1}
},
ok: false,
},
{
name: "metadata",
data: func() interface{} {
return []interface{}{map[string]interface{}{"id": "1"}}
},
ok: true,
meta: &output.Meta{Count: 1, Rollback: "lark-cli fixture rollback"},
},
{
name: "jq_scalar",
data: func() interface{} {
return map[string]interface{}{"name": "Alice", "age": 30}
},
ok: true,
jq: ".data.name",
},
{
name: "raw_jq_complex",
data: func() interface{} {
return map[string]interface{}{"document": map[string]interface{}{"html": "<p>a&b</p>"}}
},
raw: true,
ok: true,
jq: ".data.document",
},
{
name: "jq_invalid_expression",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: false,
jq: "invalid[",
},
{
name: "notice",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: true,
notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}},
},
{
name: "pretty",
data: func() interface{} {
return map[string]interface{}{"name": "Alice"}
},
ok: true,
format: "pretty",
useFormat: true,
pretty: true,
},
{
name: "pretty_without_renderer",
data: func() interface{} {
return map[string]interface{}{"name": "Alice"}
},
ok: true,
format: "pretty",
useFormat: true,
},
{
name: "ndjson",
data: func() interface{} {
return map[string]interface{}{"items": []interface{}{
map[string]interface{}{"id": "1"},
map[string]interface{}{"id": "2"},
}}
},
ok: true,
format: "ndjson",
useFormat: true,
},
{
name: "table_with_safety_warning",
data: func() interface{} {
return []interface{}{map[string]interface{}{"id": "1", "name": "Alice"}}
},
ok: true,
format: "table",
useFormat: true,
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "csv",
data: func() interface{} {
return []interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
map[string]interface{}{"id": "2", "name": "Bob"},
}
},
ok: true,
format: "csv",
useFormat: true,
},
{
name: "jq_safety_alert_without_stderr_warning",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: true,
jq: ".data.id",
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "scanner_error_fails_open",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: true,
safetyMode: "warn",
safetyErr: errors.New("scanner unavailable"),
},
{
name: "scanner_block",
data: func() interface{} {
return map[string]interface{}{"id": "blocked"}
},
ok: false,
safetyMode: "block",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "unknown_format_data_envelope_notice",
data: func() interface{} {
return map[string]interface{}{"ok": true, "value": "fixture"}
},
ok: true,
format: "yaml",
useFormat: true,
notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}},
},
}
golden := loadRuntimeContextLegacyGolden(t)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mode := tc.safetyMode
if mode == "" {
mode = "off"
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert, err: tc.safetyErr})
t.Cleanup(func() { extcs.Register(nil) })
notice := tc.notice
output.PendingNotice = func() map[string]interface{} { return notice }
want, ok := golden.Cases[tc.name]
if !ok {
t.Fatalf("frozen golden case %q is missing", tc.name)
}
opts := runtimeOracleOptions{
raw: tc.raw,
ok: tc.ok,
meta: tc.meta,
jq: tc.jq,
format: tc.format,
useFormat: tc.useFormat,
pretty: tc.pretty,
}
current := runEmitterWithRuntimeContextContract(tc.data(), output.EmitterConfig{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
NoticeProvider: func() map[string]interface{} { return notice },
}, tc.ok, output.EmitOptions{
Raw: tc.raw,
Meta: tc.meta,
Format: tc.format,
JQ: tc.jq,
Pretty: emitterPrettyRenderer(tc.pretty),
})
assertEmitterGolden(t, want, current)
integrated := runRuntimeContextOracle(t, tc.data(), opts)
assertEmitterGolden(t, want, integrated)
if tc.safetyMode == "block" {
var safetyErr *errs.ContentSafetyError
if !errors.As(current.err, &safetyErr) {
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", current.err)
}
}
})
}
if len(golden.Cases) != len(cases) {
t.Fatalf("golden case count = %d, want %d", len(golden.Cases), len(cases))
}
jqFailure := golden.Cases["jq_invalid_expression"]
if !strings.HasPrefix(jqFailure.Stderr, "error: ") || !strings.HasSuffix(jqFailure.Stderr, "\n") {
t.Fatalf("invalid jq golden stderr = %q, want error line ending in newline", jqFailure.Stderr)
}
if jqFailure.Error == nil || jqFailure.Error.ExitCode != output.ExitValidation {
t.Fatalf("invalid jq golden exit = %#v, want %d", jqFailure.Error, output.ExitValidation)
}
}
func loadRuntimeContextLegacyGolden(t *testing.T) runtimeContextLegacyGolden {
t.Helper()
contents, err := os.ReadFile(runtimeContextLegacyGoldenPath)
if err != nil {
t.Fatalf("read RuntimeContext legacy golden: %v", err)
}
var golden runtimeContextLegacyGolden
if err := json.Unmarshal(contents, &golden); err != nil {
t.Fatalf("decode RuntimeContext legacy golden: %v", err)
}
return golden
}
func captureEmitterGolden(t *testing.T, capture emitterCapture) emitterCaptureGolden {
t.Helper()
golden := emitterCaptureGolden{Stdout: capture.stdout, Stderr: capture.stderr}
if capture.err == nil {
return golden
}
errorJSON, err := json.Marshal(capture.err)
if err != nil {
t.Fatalf("marshal captured error %T: %v", capture.err, err)
}
golden.Error = &emitterErrorGolden{
GoType: fmt.Sprintf("%T", capture.err),
JSON: errorJSON,
Message: capture.err.Error(),
ExitCode: output.ExitCodeOf(capture.err),
}
return golden
}
type runtimeOracleOptions struct {
raw bool
ok bool
meta *output.Meta
jq string
format string
useFormat bool
pretty bool
}
func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
t.Helper()
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
parent := &cobra.Command{Use: "lark-cli"}
cmd := &cobra.Command{Use: "fixture"}
leaf := &cobra.Command{Use: "+emit"}
parent.AddCommand(cmd)
cmd.AddCommand(leaf)
factory := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}}
runtime := common.TestNewRuntimeContextForAPI(
context.Background(), leaf, &core.CliConfig{Brand: core.BrandFeishu}, factory, core.AsBot,
)
runtime.Format = opts.format
runtime.JqExpr = opts.jq
pretty := func(w io.Writer) {
fmt.Fprintln(w, "pretty:fixture")
}
if !opts.pretty {
pretty = nil
}
var err error
switch {
case opts.useFormat && opts.raw:
runtime.OutFormatRaw(data, opts.meta, pretty)
case opts.useFormat:
runtime.OutFormat(data, opts.meta, pretty)
case !opts.ok:
err = runtime.OutPartialFailure(data, opts.meta)
case opts.raw:
runtime.OutRaw(data, opts.meta)
default:
runtime.Out(data, opts.meta)
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
config.Out = stdout
config.ErrOut = stderr
emitter := output.NewEmitter(config)
var err error
if ok {
err = emitter.Success(data, opts)
} else {
err = emitter.PartialFailure(data, opts)
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterWithRuntimeContextContract(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
capture := runEmitterSuccess(data, config, ok, opts)
if capture.err != nil {
var safetyErr *errs.ContentSafetyError
if errors.As(capture.err, &safetyErr) {
return capture
}
if opts.JQ != "" {
capture.stderr += fmt.Sprintf("error: %v\n", capture.err)
return capture
}
capture.err = nil
}
if !ok {
capture.err = output.PartialFailure(output.ExitAPI)
}
return capture
}
func emitterPrettyRenderer(enabled bool) output.PrettyRenderer {
if !enabled {
return nil
}
return func(w io.Writer, _ bool) error {
_, err := fmt.Fprintln(w, "pretty:fixture")
return err
}
}
func TestEmitterMatchesWriteSuccessEnvelopeLegacyOracle(t *testing.T) {
previousNotice := output.PendingNotice
t.Cleanup(func() {
output.PendingNotice = previousNotice
extcs.Register(nil)
})
cases := []writeSuccessEnvelopeOracleCase{
{
name: "json",
data: func() interface{} { return map[string]interface{}{"id": "1"} },
},
{
name: "dry_run",
data: func() interface{} { return map[string]interface{}{"api": []interface{}{}} },
dryRun: true,
},
{
name: "jq",
data: func() interface{} { return map[string]interface{}{"id": "1"} },
jq: ".data.id",
},
{
name: "notice",
data: func() interface{} { return map[string]interface{}{"id": "1"} },
notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}},
},
{
name: "jq_safety_warning",
data: func() interface{} { return map[string]interface{}{"id": "1"} },
jq: ".data.id",
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "scanner_block",
data: func() interface{} { return map[string]interface{}{"id": "blocked"} },
safetyMode: "block",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
}
golden := loadWriteSuccessEnvelopeLegacyGolden(t)
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mode := tc.safetyMode
if mode == "" {
mode = "off"
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert})
t.Cleanup(func() { extcs.Register(nil) })
notice := tc.notice
output.PendingNotice = func() map[string]interface{} { return notice }
want, ok := golden.Cases[tc.name]
if !ok {
t.Fatalf("frozen golden case %q is missing", tc.name)
}
current := runEmitterSuccess(tc.data(), output.EmitterConfig{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
NoticeProvider: func() map[string]interface{} { return notice },
}, true, output.EmitOptions{
Format: "",
Raw: false,
JQ: tc.jq,
DryRun: tc.dryRun,
JQSafetyWarning: true,
})
assertEmitterGolden(t, want, current)
integrated := runWriteSuccessEnvelopeOracle(tc.data(), tc.dryRun, tc.jq)
assertEmitterGolden(t, want, integrated)
})
}
if len(golden.Cases) != len(cases) {
t.Fatalf("golden case count = %d, want %d", len(golden.Cases), len(cases))
}
}
func loadWriteSuccessEnvelopeLegacyGolden(t *testing.T) writeSuccessEnvelopeLegacyGolden {
t.Helper()
contents, err := os.ReadFile(writeSuccessEnvelopeLegacyGoldenPath)
if err != nil {
t.Fatalf("read WriteSuccessEnvelope legacy golden: %v", err)
}
var golden writeSuccessEnvelopeLegacyGolden
if err := json.Unmarshal(contents, &golden); err != nil {
t.Fatalf("decode WriteSuccessEnvelope legacy golden: %v", err)
}
return golden
}
func runWriteSuccessEnvelopeOracle(data interface{}, dryRun bool, jq string) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
err := output.WriteSuccessEnvelope(data, output.SuccessEnvelopeOptions{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
DryRun: dryRun,
JqExpr: jq,
Out: stdout,
ErrOut: stderr,
})
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
t.Cleanup(func() { extcs.Register(nil) })
type oracleCase struct {
name string
format output.Format
safetyMode string
safetyAlert *extcs.Alert
}
cases := []oracleCase{
{name: "ndjson", format: output.FormatNDJSON},
{name: "table", format: output.FormatTable},
{name: "csv", format: output.FormatCSV},
{
name: "warn",
format: output.FormatNDJSON,
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "block",
format: output.FormatTable,
safetyMode: "block",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
}
pages := []interface{}{
[]interface{}{map[string]interface{}{"id": "1", "name": "Alice"}},
[]interface{}{map[string]interface{}{"id": "2", "name": "Bob", "ignored": true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mode := tc.safetyMode
if mode == "" {
mode = "off"
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert})
t.Cleanup(func() { extcs.Register(nil) })
legacy := runPaginationOracle(pages, tc.format)
current := runEmitterStreamPages(pages, tc.format.String())
assertEmitterBytes(t, legacy, current)
assertEquivalentError(t, legacy.err, current.err)
})
}
}
func runPaginationOracle(pages []interface{}, format output.Format) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
formatter := output.NewPaginatedFormatter(stdout, format)
var emitErr error
for _, page := range pages {
scanResult := output.ScanForSafety("lark-cli fixture +emit", page, stderr)
if scanResult.Blocked {
emitErr = scanResult.BlockErr
break
}
if scanResult.Alert != nil {
output.WriteAlertWarning(stderr, scanResult.Alert)
}
formatter.FormatPage(page)
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
}
func runEmitterStreamPages(pages []interface{}, format string) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: stderr,
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
})
var emitErr error
for _, page := range pages {
if emitErr = emitter.StreamPage(page, output.StreamOptions{Format: format}); emitErr != nil {
break
}
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
}
func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
previousNotice := output.PendingNotice
output.PendingNotice = func() map[string]interface{} {
return map[string]interface{}{"source": "global"}
}
t.Cleanup(func() { output.PendingNotice = previousNotice })
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
colorSeen := false
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: stderr,
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
ColorEnabled: true,
NoticeProvider: func() map[string]interface{} {
return map[string]interface{}{"source": "captured"}
},
})
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
t.Fatalf("notice source was not captured by Emitter:\n%s", stdout.String())
}
stdout.Reset()
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "pretty",
Pretty: func(w io.Writer, colorEnabled bool) error {
colorSeen = colorEnabled
_, err := fmt.Fprintln(w, "pretty")
return err
},
}); err != nil {
t.Fatalf("Emitter.Success(pretty) error = %v", err)
}
if !colorSeen {
t.Fatal("PrettyRenderer did not receive captured ColorEnabled value")
}
stdout.Reset()
if err := emitter.Success(map[string]interface{}{"ok": true, "id": "1"}, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success(unknown format) error = %v", err)
}
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String())
}
}
type failingEmitterWriter struct {
err error
}
func (w failingEmitterWriter) Write([]byte) (int, error) { return 0, w.err }
func TestEmitterPropagatesOutputError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
sentinel := errors.New("write failed")
emitter := output.NewEmitter(output.EmitterConfig{
Out: failingEmitterWriter{err: sentinel},
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Raw: true, Format: "json",
JQ: ".data",
})
if !errors.Is(err, sentinel) {
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
}
}
func assertEmitterBytes(t *testing.T, legacy, current emitterCapture) {
t.Helper()
if legacy.stdout != current.stdout {
t.Fatalf("stdout byte mismatch\nlegacy (%d bytes):\n%q\nEmitter (%d bytes):\n%q",
len(legacy.stdout), legacy.stdout, len(current.stdout), current.stdout)
}
if legacy.stderr != current.stderr {
t.Fatalf("stderr byte mismatch\nlegacy (%d bytes):\n%q\nEmitter (%d bytes):\n%q",
len(legacy.stderr), legacy.stderr, len(current.stderr), current.stderr)
}
}
func assertEmitterGolden(t *testing.T, want emitterCaptureGolden, current emitterCapture) {
t.Helper()
if want.Stdout != current.stdout {
t.Fatalf("stdout byte mismatch\ngolden (%d bytes):\n%q\ncurrent (%d bytes):\n%q",
len(want.Stdout), want.Stdout, len(current.stdout), current.stdout)
}
if want.Stderr != current.stderr {
t.Fatalf("stderr byte mismatch\ngolden (%d bytes):\n%q\ncurrent (%d bytes):\n%q",
len(want.Stderr), want.Stderr, len(current.stderr), current.stderr)
}
got := captureEmitterGolden(t, current)
if (want.Error == nil) != (got.Error == nil) {
t.Fatalf("error presence mismatch: golden=%#v current=%#v", want.Error, got.Error)
}
if want.Error == nil {
return
}
if want.Error.GoType != got.Error.GoType || want.Error.Message != got.Error.Message || want.Error.ExitCode != got.Error.ExitCode {
t.Fatalf("error mismatch:\ngolden: %#v\ncurrent: %#v", want.Error, got.Error)
}
var wantJSON interface{}
if err := json.Unmarshal(want.Error.JSON, &wantJSON); err != nil {
t.Fatalf("decode golden error JSON: %v", err)
}
var gotJSON interface{}
if err := json.Unmarshal(got.Error.JSON, &gotJSON); err != nil {
t.Fatalf("decode current error JSON: %v", err)
}
if !reflect.DeepEqual(wantJSON, gotJSON) {
t.Fatalf("error JSON mismatch:\ngolden: %s\ncurrent: %s", want.Error.JSON, got.Error.JSON)
}
}
func assertEquivalentError(t *testing.T, legacy, current error) {
t.Helper()
if (legacy == nil) != (current == nil) {
t.Fatalf("error presence mismatch: legacy=%v Emitter=%v", legacy, current)
}
if legacy == nil {
return
}
legacyProblem, legacyOK := errs.ProblemOf(legacy)
currentProblem, currentOK := errs.ProblemOf(current)
if legacyOK != currentOK {
t.Fatalf("typed error mismatch: legacy=%T Emitter=%T", legacy, current)
}
if legacyOK && !reflect.DeepEqual(legacyProblem, currentProblem) {
t.Fatalf("problem mismatch:\nlegacy: %#v\nEmitter: %#v", legacyProblem, currentProblem)
}
}

View File

@@ -7,7 +7,6 @@ package output
type Envelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
Data interface{} `json:"data,omitempty"`
Meta *Meta `json:"meta,omitempty"`
ContentSafetyAlert interface{} `json:"_content_safety_alert,omitempty"`

View File

@@ -9,7 +9,6 @@ import "io"
type SuccessEnvelopeOptions struct {
CommandPath string
Identity string
DryRun bool
JqExpr string
Out io.Writer
ErrOut io.Writer
@@ -34,17 +33,26 @@ func SuccessEnvelopeData(result interface{}) interface{} {
// JSON output carries content-safety alerts inside the envelope. When jq is
// applied, the alert may be filtered away, so warn mode also writes stderr.
func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
return NewEmitter(EmitterConfig{
Out: opts.Out,
ErrOut: opts.ErrOut,
CommandPath: opts.CommandPath,
Identity: opts.Identity,
NoticeProvider: GetNotice,
}).Success(data, EmitOptions{
Format: "",
Raw: false,
JQ: opts.JqExpr,
DryRun: opts.DryRun,
JQSafetyWarning: true,
})
scanResult := ScanForSafety(opts.CommandPath, data, opts.ErrOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
env := Envelope{
OK: true,
Identity: opts.Identity,
Data: data,
Notice: GetNotice(),
}
if scanResult.Alert != nil {
env.ContentSafetyAlert = scanResult.Alert
}
if opts.JqExpr != "" {
if scanResult.Alert != nil && opts.ErrOut != nil {
WriteAlertWarning(opts.ErrOut, scanResult.Alert)
}
return JqFilter(opts.Out, env, opts.JqExpr)
}
PrintJson(opts.Out, env)
return nil
}

View File

@@ -104,47 +104,6 @@ func TestWriteSuccessEnvelope_JqUsesEnvelope(t *testing.T) {
}
}
func TestWriteSuccessEnvelope_DryRunMarker(t *testing.T) {
var out strings.Builder
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
Identity: "bot",
DryRun: true,
Out: &out,
})
if err != nil {
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
}
var env map[string]interface{}
if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
}
if env["ok"] != true || env["identity"] != "bot" || env["dry_run"] != true {
t.Fatalf("unexpected dry-run envelope: %#v", env)
}
if _, ok := env["data"].(map[string]interface{}); !ok {
t.Fatalf("data = %#v, want object", env["data"])
}
}
func TestWriteSuccessEnvelope_DryRunJqUsesEnvelope(t *testing.T) {
var out strings.Builder
err := WriteSuccessEnvelope(map[string]interface{}{"api": []interface{}{}}, SuccessEnvelopeOptions{
Identity: "bot",
DryRun: true,
JqExpr: ".dry_run",
Out: &out,
})
if err != nil {
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
}
if strings.TrimSpace(out.String()) != "true" {
t.Fatalf("jq output = %q, want true", out.String())
}
}
func TestWriteSuccessEnvelope_JqWarnsWhenSafetyAlertFiltered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
extcs.Register(&mockProvider{

View File

@@ -101,44 +101,34 @@ func ExtractItems(data interface{}) []interface{} {
// FormatValue formats a single response and writes it to w.
func FormatValue(w io.Writer, data interface{}, format Format) {
err := WriteFormatted(w, data, format)
switch {
case err == nil:
return
case isOutputMarshalError(err) && format == FormatNDJSON:
legacyStderrf("ndjson marshal error: %v\n", err)
case isOutputMarshalError(err):
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteFormatted formats a single response and returns marshal or write errors.
func WriteFormatted(w io.Writer, data interface{}, format Format) error {
data = toGeneric(data)
switch format {
case FormatNDJSON:
items := ExtractItems(data)
if items != nil {
return WriteNDJSON(w, items)
PrintNdjson(w, items)
} else {
PrintNdjson(w, data)
}
return WriteNDJSON(w, data)
case FormatTable:
items := ExtractItems(data)
if items != nil {
return WriteTable(w, items)
FormatAsTable(w, items)
} else {
FormatAsTable(w, data)
}
return WriteTable(w, data)
case FormatCSV:
items := ExtractItems(data)
if items != nil {
return WriteCSV(w, items)
FormatAsCSV(w, items)
} else {
FormatAsCSV(w, data)
}
return WriteCSV(w, data)
default: // FormatJSON
return WriteJSON(w, data)
PrintJson(w, data)
}
}
@@ -158,63 +148,49 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
// FormatPage formats one page of items.
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
err := pf.WritePage(data)
if isOutputMarshalError(err) && (pf.Format == FormatJSON || pf.Format == FormatNDJSON) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
// WritePage formats one page of items and returns marshal or write errors.
func (pf *PaginatedFormatter) WritePage(data interface{}) error {
switch pf.Format {
case FormatJSON, FormatNDJSON:
if arr, ok := data.([]interface{}); ok {
return WriteNDJSON(pf.W, arr)
PrintNdjson(pf.W, arr)
} else {
PrintNdjson(pf.W, data)
}
return WriteNDJSON(pf.W, data)
case FormatTable:
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
widths := computeColumnWidths(rows, cols)
if isFirst {
if err := writeHeader(w, cols, widths); err != nil {
return err
}
writeHeader(w, cols, widths)
}
for _, row := range rows {
if err := writeRow(w, row, cols, widths); err != nil {
return err
}
writeRow(w, row, cols, widths)
}
return nil
})
case FormatCSV:
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
return writeCSVRows(w, rows, cols, isFirst)
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
writeCSVRows(w, rows, cols, isFirst)
})
}
return nil
}
// formatStructuredPage handles column-locking logic shared by table and csv.
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool) error) error {
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool)) {
rows, pageCols, isList := prepareRows(data)
if len(rows) == 0 {
if pf.isFirstPage && isList {
_, err := fmt.Fprintln(pf.W, "(empty)")
return err
fmt.Fprintln(pf.W, "(empty)")
}
return nil
return
}
if pf.isFirstPage {
// Lock columns from first page
pf.cols = pageCols
pf.isFirstPage = false
return emit(pf.W, rows, pf.cols, true)
emit(pf.W, rows, pf.cols, true)
} else {
// Reuse first page's columns — missing keys become empty, extra keys ignored
return emit(pf.W, rows, pf.cols, false)
emit(pf.W, rows, pf.cols, false)
}
}

View File

@@ -5,7 +5,6 @@ package output
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -16,44 +15,12 @@ import (
// PrintJson prints data as formatted JSON to w.
func PrintJson(w io.Writer, data interface{}) {
injectNotice(data)
if err := WriteJSON(w, data); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
type outputMarshalError struct {
err error
}
func (e *outputMarshalError) Error() string {
return e.err.Error()
}
func (e *outputMarshalError) Unwrap() error {
return e.err
}
func isOutputMarshalError(err error) bool {
var marshalErr *outputMarshalError
return errors.As(err, &marshalErr)
}
// legacyStderrf reports a leaf-formatter marshal/format failure on os.Stderr,
// preserving the pre-Emitter behavior for direct (unmigrated) callers of the
// Print*/FormatAs* wrappers. The Emitter never uses this — it returns typed
// errors instead. Removed once the remaining direct callers migrate.
func legacyStderrf(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, format, args...) //nolint:forbidigo // legacy leaf-formatter stderr; removed in the output-ownership follow-up
}
// WriteJSON writes data as formatted JSON to w and returns marshal or write errors.
func WriteJSON(w io.Writer, data interface{}) error {
b, err := json.MarshalIndent(data, "", " ")
if err != nil {
return &outputMarshalError{err: err}
fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
return
}
_, err = fmt.Fprintln(w, string(b))
return err
fmt.Fprintln(w, string(b))
}
// injectNotice adds a "_notice" field into CLI envelope maps.
@@ -83,38 +50,21 @@ func injectNotice(data interface{}) {
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
func PrintNdjson(w io.Writer, data interface{}) {
if arr, ok := data.([]interface{}); ok {
for _, item := range arr {
if err := WriteNDJSON(w, item); isOutputMarshalError(err) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
return
}
if err := WriteNDJSON(w, data); isOutputMarshalError(err) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
// WriteNDJSON writes data as NDJSON and returns marshal or write errors.
func WriteNDJSON(w io.Writer, data interface{}) error {
emit := func(item interface{}) error {
emit := func(item interface{}) {
b, err := json.Marshal(item)
if err != nil {
return &outputMarshalError{err: err}
fmt.Fprintf(os.Stderr, "ndjson marshal error: %v\n", err)
return
}
_, err = fmt.Fprintln(w, string(b))
return err
fmt.Fprintln(w, string(b))
}
if arr, ok := data.([]interface{}); ok {
for _, item := range arr {
if err := emit(item); err != nil {
return err
}
emit(item)
}
return nil
} else {
emit(data)
}
return emit(data)
}
func cellStr(val interface{}) string {

View File

@@ -16,69 +16,50 @@ const maxColWidth = 100
// - map[string]interface{} (single object) → key-value two-column table
// - empty array → "(empty)"
func FormatAsTable(w io.Writer, data interface{}) {
if err := WriteTable(w, data); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteTable formats data as a table and returns marshal or write errors.
func WriteTable(w io.Writer, data interface{}) error {
return WriteTablePaginated(w, data, true)
FormatAsTablePaginated(w, data, true)
}
// FormatAsTablePaginated formats data as a table with pagination awareness.
// When isFirstPage is true, outputs the header; otherwise only data rows.
func FormatAsTablePaginated(w io.Writer, data interface{}, isFirstPage bool) {
if err := WriteTablePaginated(w, data, isFirstPage); isOutputMarshalError(err) {
legacyStderrf("json marshal error: %v\n", err)
}
}
// WriteTablePaginated formats data as a table and returns marshal or write errors.
func WriteTablePaginated(w io.Writer, data interface{}, isFirstPage bool) error {
rows, cols, isList := prepareRows(data)
if cols == nil {
if isList {
_, err := fmt.Fprintln(w, "(empty)")
return err
fmt.Fprintln(w, "(empty)")
} else {
// Not a list and not an object — print as JSON fallback
return WriteJSON(w, data)
PrintJson(w, data)
}
return
}
if len(rows) == 0 {
if isFirstPage {
_, err := fmt.Fprintln(w, "(empty)")
return err
fmt.Fprintln(w, "(empty)")
}
return nil
return
}
if !isList {
// Single object: key-value two-column format
return formatKeyValueTable(w, rows[0], cols)
formatKeyValueTable(w, rows[0], cols)
return
}
// Calculate column widths (clamped to maxColWidth)
widths := computeColumnWidths(rows, cols)
if isFirstPage {
if err := writeHeader(w, cols, widths); err != nil {
return err
}
writeHeader(w, cols, widths)
}
for _, row := range rows {
if err := writeRow(w, row, cols, widths); err != nil {
return err
}
writeRow(w, row, cols, widths)
}
return nil
}
// formatKeyValueTable renders a single object as a two-column key-value table.
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) error {
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
maxKeyWidth := 0
for _, col := range cols {
kw := stringWidth(col)
@@ -90,11 +71,8 @@ func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) erro
for _, col := range cols {
val := row[col]
val = truncateToWidth(val, maxColWidth)
if _, err := fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val); err != nil {
return err
}
fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val)
}
return nil
}
// computeColumnWidths returns display widths for each column, clamped to maxColWidth.
@@ -121,29 +99,25 @@ func computeColumnWidths(rows []map[string]string, cols []string) []int {
}
// writeHeader writes the header row and separator line.
func writeHeader(w io.Writer, cols []string, widths []int) error {
func writeHeader(w io.Writer, cols []string, widths []int) {
var header []string
var sep []string
for i, col := range cols {
header = append(header, padToWidth(col, widths[i]))
sep = append(sep, strings.Repeat("─", widths[i]))
}
if _, err := fmt.Fprintln(w, strings.Join(header, " ")); err != nil {
return err
}
_, err := fmt.Fprintln(w, strings.Join(sep, " "))
return err
fmt.Fprintln(w, strings.Join(header, " "))
fmt.Fprintln(w, strings.Join(sep, " "))
}
// writeRow writes a single data row.
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) error {
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) {
var cells []string
for i, col := range cols {
val := truncateToWidth(row[col], widths[i])
cells = append(cells, padToWidth(val, widths[i]))
}
_, err := fmt.Fprintln(w, strings.Join(cells, " "))
return err
fmt.Fprintln(w, strings.Join(cells, " "))
}
// padToWidth pads a string with spaces to reach the target display width.

View File

@@ -1,107 +0,0 @@
{
"cases": {
"csv": {
"stdout": "id,name\n1,Alice\n2,Bob\n",
"stderr": ""
},
"format_raw_json_preserves_html": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
"stderr": ""
},
"jq_invalid_expression": {
"stdout": "",
"stderr": "error: invalid jq expression: unexpected EOF\n",
"error": {
"go_type": "*errs.ValidationError",
"json": {
"type": "validation",
"subtype": "invalid_argument",
"message": "invalid jq expression: unexpected EOF"
},
"message": "invalid jq expression: unexpected EOF",
"exit_code": 2
}
},
"jq_safety_alert_without_stderr_warning": {
"stdout": "1\n",
"stderr": ""
},
"jq_scalar": {
"stdout": "Alice\n",
"stderr": ""
},
"json_object": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"enabled\": true,\n \"id\": \"1\"\n }\n}\n",
"stderr": ""
},
"metadata": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": [\n {\n \"id\": \"1\"\n }\n ],\n \"meta\": {\n \"count\": 1,\n \"rollback\": \"lark-cli fixture rollback\"\n }\n}\n",
"stderr": ""
},
"ndjson": {
"stdout": "{\"id\":\"1\"}\n{\"id\":\"2\"}\n",
"stderr": ""
},
"notice": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n },\n \"_notice\": {\n \"update\": {\n \"latest\": \"9.9.9\"\n }\n }\n}\n",
"stderr": ""
},
"partial_failure_ok_false": {
"stdout": "{\n \"ok\": false,\n \"identity\": \"bot\",\n \"data\": {\n \"failed\": 1,\n \"succeeded\": 1\n }\n}\n",
"stderr": "",
"error": {
"go_type": "*output.PartialFailureError",
"json": {
"Code": 1
},
"message": "partial failure (exit 1)",
"exit_code": 1
}
},
"pretty": {
"stdout": "pretty:fixture\n",
"stderr": ""
},
"pretty_without_renderer": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"name\": \"Alice\"\n }\n}\n",
"stderr": ""
},
"raw_jq_complex": {
"stdout": "{\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n}\n",
"stderr": ""
},
"raw_json_preserves_html": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
"stderr": ""
},
"scanner_block": {
"stdout": "",
"stderr": "",
"error": {
"go_type": "*errs.ContentSafetyError",
"json": {
"type": "policy",
"subtype": "content_safety",
"message": "content safety violation detected (rules: fixture-rule)",
"rules": [
"fixture-rule"
]
},
"message": "content safety violation detected (rules: fixture-rule)",
"exit_code": 6
}
},
"scanner_error_fails_open": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
"stderr": "warning: content safety scan error: scanner unavailable\n"
},
"table_with_safety_warning": {
"stdout": "id name \n── ─────\n1 Alice\n",
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
},
"unknown_format_data_envelope_notice": {
"stdout": "{\n \"_notice\": {\n \"skills\": {\n \"current\": \"1.0.0\"\n }\n },\n \"ok\": true,\n \"value\": \"fixture\"\n}\n",
"stderr": "warning: unknown format \"yaml\", falling back to json\n"
}
}
}

View File

@@ -1,41 +0,0 @@
{
"cases": {
"dry_run": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"dry_run\": true,\n \"data\": {\n \"api\": []\n }\n}\n",
"stderr": ""
},
"jq": {
"stdout": "1\n",
"stderr": ""
},
"jq_safety_warning": {
"stdout": "1\n",
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
},
"json": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
"stderr": ""
},
"notice": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n },\n \"_notice\": {\n \"update\": {\n \"latest\": \"9.9.9\"\n }\n }\n}\n",
"stderr": ""
},
"scanner_block": {
"stdout": "",
"stderr": "",
"error": {
"go_type": "*errs.ContentSafetyError",
"json": {
"type": "policy",
"subtype": "content_safety",
"message": "content safety violation detected (rules: fixture-rule)",
"rules": [
"fixture-rule"
]
},
"message": "content safety violation detected (rules: fixture-rule)",
"exit_code": 6
}
}
}
}

View File

@@ -19,18 +19,12 @@ import (
type eventPayload struct {
Comment *struct {
Body string `json:"body"`
Path string `json:"path"`
} `json:"comment"`
Review *struct {
Body string `json:"body"`
} `json:"review"`
}
type commentContent struct {
Body string
Path string
}
func main() {
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
@@ -40,11 +34,12 @@ func main() {
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
os.Exit(2)
}
diags, err := auditEvent(*eventPath, *kind)
body, err := commentBody(*eventPath)
if err != nil {
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
os.Exit(2)
}
diags := diagnostics(publiccontent.ScanComment(*kind, body))
if len(diags) > 0 {
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
}
@@ -52,44 +47,32 @@ func main() {
os.Exit(report.ExitCode(diags))
}
func auditEvent(eventPath, kind string) ([]report.Diagnostic, error) {
content, err := commentBody(eventPath)
if err != nil {
return nil, err
}
return scanCommentContent(kind, content), nil
}
func scanCommentContent(kind string, content commentContent) []report.Diagnostic {
return diagnostics(publiccontent.ScanCommentAtPath(kind, content.Path, content.Body))
}
func auditFailureSummary(count int) string {
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
}
func commentBody(path string) (commentContent, error) {
func commentBody(path string) (string, error) {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return commentContent{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
WithParam("--event").
WithCause(err)
}
data, err := vfs.ReadFile(safePath)
if err != nil {
return commentContent{}, err
return "", err
}
var payload eventPayload
if err := json.Unmarshal(data, &payload); err != nil {
return commentContent{}, err
return "", err
}
switch {
case payload.Comment != nil:
return commentContent{Body: payload.Comment.Body, Path: payload.Comment.Path}, nil
return payload.Comment.Body, nil
case payload.Review != nil:
return commentContent{Body: payload.Review.Body}, nil
return payload.Review.Body, nil
default:
return commentContent{}, nil
return "", nil
}
}

View File

@@ -7,11 +7,9 @@ import (
"errors"
"os"
"path/filepath"
"strconv"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
)
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
@@ -34,92 +32,11 @@ func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
if got.Body != "clean comment" || got.Path != "" {
t.Fatalf("comment content = %#v", got)
if got != "clean comment" {
t.Fatalf("comment body = %q", got)
}
}
func TestCommentBodyReadsReviewCommentPath(t *testing.T) {
dir := t.TempDir()
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"test suggestion","path":"cmd/agent/list_test.go"}}`); err != nil {
t.Fatal(err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
got, err := commentBody("event.json")
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
if got.Body != "test suggestion" || got.Path != "cmd/agent/list_test.go" {
t.Fatalf("comment content = %#v", got)
}
}
func TestCommentAuditUsesReviewCommentPathForFixtureClassification(t *testing.T) {
dir := t.TempDir()
body := `CLIENT_SECRET=$(security find-generic-password -w)`
event := `{"comment":{"body":` + strconv.Quote(body) + `,"path":"scripts/config_test.sh"}}`
if err := writeTestFile(filepath.Join(dir, "event.json"), event); err != nil {
t.Fatal(err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
diags, err := auditEvent("event.json", "pull_request_review_comment")
if err != nil {
t.Fatalf("auditEvent() error = %v", err)
}
for _, diag := range diags {
if diag.Rule == "public_content_generic_credential" {
t.Fatalf("review comment fixture should not be a credential diagnostic: %#v", diags)
}
}
pathless := publiccontent.ScanComment("pull_request_review_comment", body)
for _, finding := range pathless {
if finding.Rule == "public_content_generic_credential" {
return
}
}
t.Fatalf("test precondition failed: pathless comment should be classified as a credential: %#v", pathless)
}
func TestScanCommentContentPreservesReviewCommentPath(t *testing.T) {
providerValue := "gh" + "p_" + "1234567890abcdef" + "1234567890abcdef" + "1234"
content := commentContent{
Body: `cfg := &Config{AccessToken: "` + providerValue + `"}`,
Path: "cmd/agent/list_test.go",
}
diags := scanCommentContent("pull_request_review_comment", content)
for _, diag := range diags {
if diag.Rule != "public_content_generic_credential" {
continue
}
if diag.File != content.Path {
t.Fatalf("credential diagnostic file = %q, want %q", diag.File, content.Path)
}
return
}
t.Fatalf("missing provider credential diagnostic: %#v", diags)
}
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "event.json")
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {

View File

@@ -45,18 +45,6 @@ Adding a new row requires approval from the matching CODEOWNERS or quality gate
`legacy-commands.txt` only covers hand-authored legacy commands. Generated OpenAPI service commands are intentionally excluded from `command-manifest.json`; they are included in `command-index.json` only so command references can be checked against the real CLI surface.
## Public Domain Allowlists
`internal/qualitygate/config/allowlists/public-domains.txt` contains supported public hostnames approved for Go source. `fixture-domains.txt` contains test-only hostnames used by `*_test.go`, the repository-root `tests/` directory, or any `testdata/` directory; fixture entries do not apply to production Go files or `skills/`.
Keep one lowercase exact hostname per line, sorted alphabetically. Wildcards, suffix rules, duplicates, schemes, ports, and paths are rejected; approving `larkoffice.com` does not approve its subdomains.
RFC 2606 reserves the `.test`, `.example`, `.invalid`, and `.localhost` namespaces plus the exact names `example.com`, `example.net`, and `example.org`. These names are accepted without an allowlist entry and must not be listed.
Every public entry needs a current non-fixture Go use, evidence that it is a supported public endpoint, and CODEOWNER approval. Other test-only hostnames belong in the fixture list. Tenant-specific, private-control-plane, and internal API hostnames are not eligible.
`lint/domaincontract` validates both lists and scans complete Go files. In CI, unapproved-host findings are limited to values whose expressions intersect added lines; list validation and unused-entry checks remain repository-wide. See `lint/README.md` for scanner semantics.
## Semantic Blocker Policy
The semantic reviewer can propose findings, but the local gatekeeper recomputes whether each finding is reproducible from `facts.json`. A finding blocks only when all of these are true:

View File

@@ -1,24 +0,0 @@
# Exact test-only hostnames. Keep sorted.
abc.feishu.cn
attacker.example.com
bytedance.feishu.cn
cdn.feishu.cn
evil.example.com
example.feishu.cn
example.larkoffice.com
example.larksuite.com
feishu.cn
feishu.doubao.com
gateway.docker.internal
host.containers.internal
host.docker.internal
host.lima.internal
lf3-static.bytednsdoc.com
meetings.feishu.cn
meetings.larksuite.com
p3-lark-file.byteimg.com
passport.feishu.cn
sample.feishu.cn
x.feishu.cn
xxx.feishu.cn
xxx.larksuite.com

View File

@@ -1,18 +0,0 @@
# Exact public hostnames. Keep sorted.
accounts.feishu.cn
accounts.larksuite.com
applink.feishu.cn
applink.larksuite.com
ark.ap-southeast.bytepluses.com
github.com
larkoffice.com
lf-larkemail.bytetos.com
mcp.feishu.cn
mcp.larksuite.com
open.feishu.cn
open.larksuite.com
registry.npmjs.org
registry.npmmirror.com
sf16-sg.tiktokcdn.com
www.feishu.cn
www.larksuite.com

View File

@@ -6,11 +6,10 @@ package diff
import (
"context"
"os"
"os/exec"
"path/filepath"
"reflect"
"testing"
"github.com/larksuite/cli/internal/testutil/gitcmd"
)
func TestScopeIncludesChangedSkillAndRelatedDomain(t *testing.T) {
@@ -123,7 +122,8 @@ func writeFile(t *testing.T, repo, rel, content string) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
cmd := gitcmd.Command(repo, args...)
cmd := exec.Command("git", args...)
cmd.Dir = repo
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
@@ -131,7 +131,8 @@ func runGit(t *testing.T, repo string, args ...string) {
func gitOutput(t *testing.T, repo string, args ...string) string {
t.Helper()
cmd := gitcmd.Command(repo, args...)
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.Output()
if err != nil {
t.Fatalf("git %v failed: %v", args, err)

View File

@@ -6,11 +6,10 @@ package publiccontent
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/testutil/gitcmd"
)
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
@@ -24,10 +23,9 @@ func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
runGit(t, repo, "add", "baseline.md")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
api_`+`key = "`+providerValue+`"
api_`+`key = "example-public-key"
`)
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
@@ -201,14 +199,13 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"access_` + `token":"` + providerValue + `"}`,
`{"client_` + `secret": "` + providerValue + `"}`,
`{"tenantAccess` + `Token":"` + providerValue + `"}`,
`{"github` + `Token":"` + providerValue + `"}`,
`{"vendorApi` + `Key":"` + providerValue + `"}`,
`{"slackBot` + `Token":"xoxb_` + `1234567890abcdef"}`,
`{"access_` + `token":"real-json-token"}`,
`{"client_` + `secret": "real ` + `secret value"}`,
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
`{"github` + `Token":"real-github-token"}`,
`{"vendorApi` + `Key":"real-vendor-key"}`,
`{"slackBot` + `Token":"xoxb-real-token"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add json config")
@@ -218,7 +215,14 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
for _, forbidden := range []string{providerValue, "xoxb_" + "1234567890abcdef"} {
for _, forbidden := range []string{
"real-json-token",
"real secret value",
"real-tenant-camel-token",
"real-github-token",
"real-vendor-key",
"xoxb-real-token",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -302,8 +306,8 @@ func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
count++
}
}
if count != 2 {
t.Fatalf("angle-wrapped provider credential findings = %d, want 2: %#v", count, got)
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
}
}
@@ -334,12 +338,12 @@ func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
count++
}
}
if count != 4 {
t.Fatalf("provider-shaped benign-key findings = %d, want 4: %#v", count, got)
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
}
}
func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
@@ -354,11 +358,15 @@ func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
count++
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
@@ -366,7 +374,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
@@ -383,7 +391,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
continue
}
count++
if strings.Contains(item.Excerpt, accessKey) {
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
}
}
@@ -424,7 +432,7 @@ func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
}
}
func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
@@ -440,11 +448,15 @@ func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T)
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
count++
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
@@ -477,13 +489,12 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: " + providerValue,
"TOKEN_GITHUB: " + providerValue,
"CLIENT_SECRET_GOOGLE: " + providerValue,
"SECRET_KEY_BASE: " + providerValue,
"APP_PASSWORD_PROD: " + providerValue,
"API_KEY_OPENAI: real-openai-key",
"TOKEN_GITHUB: real-github-token",
"CLIENT_SECRET_GOOGLE: real-google-secret",
"SECRET_KEY_BASE: real-secret-key-base",
"APP_PASSWORD_PROD: real-prod-password",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
@@ -495,7 +506,13 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
continue
}
count++
for _, forbidden := range []string{providerValue} {
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -604,8 +621,7 @@ func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add scanner fixtures")
@@ -669,11 +685,10 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add special paths")
@@ -840,7 +855,8 @@ func runGit(t *testing.T, repo string, args ...string) {
if len(args) > 0 && args[0] == "commit" {
args = append([]string{"commit", "--no-verify"}, args[1:]...)
}
cmd := gitcmd.Command(repo, args...)
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
@@ -849,7 +865,8 @@ func runGit(t *testing.T, repo string, args ...string) {
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
t.Helper()
cmd := gitcmd.Command(repo, args...)
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)

View File

@@ -4,15 +4,8 @@
package publiccontent
func ScanComment(kind, body string) []Finding {
return ScanCommentAtPath(kind, "", body)
}
func ScanCommentAtPath(kind, path, body string) []Finding {
if kind == "" {
kind = "comment"
}
if path == "" {
path = kind
}
return scanText(path, "comment", body, isDetectorRuleFile(path))
return scanText(kind, "comment", body, false)
}

View File

@@ -3,10 +3,7 @@
package publiccontent
import (
"strings"
"testing"
)
import "testing"
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
@@ -20,60 +17,3 @@ func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
}
}
}
func TestScanCommentAllowsMermaidCredentialTerminology(t *testing.T) {
body := strings.Join([]string{
"```mermaid",
"sequenceDiagram",
" participant Client",
" participant AccessTokenHashTransport",
" participant SecurityPolicyTransport",
" Client->>AccessTokenHashTransport: Send request with bearer token",
" AccessTokenHashTransport->>AccessTokenHashTransport: Clone request and inject token hash",
" Client -> ClientSecret: Resolve configured credential",
" AccessTokenHashTransport->>SecurityPolicyTransport: Forward enriched request",
"```",
}, "\n")
got := ScanComment("issue_comment", body)
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("mermaid credential terminology should not be a credential finding: %#v", got)
}
}
}
func TestScanCommentDetectsCredentialAssignmentInsideMermaidMessage(t *testing.T) {
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
credentialAssignment := "password=" + providerValue
body := strings.Join([]string{
"```mermaid",
"sequenceDiagram",
" Client->>Server: Send " + credentialAssignment,
"```",
}, "\n")
got := ScanComment("issue_comment", body)
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("credential assignment inside mermaid message should be reported: %#v", got)
}
}
func TestScanCommentAtPathAllowsTestFixtureCredentialPlaceholder(t *testing.T) {
body := `cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret"}`
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("review comment test fixture should not be a credential finding: %#v", got)
}
}
}
func TestScanCommentAtPathDetectsProviderCredentialInTestFile(t *testing.T) {
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
body := `cfg := &Config{AccessToken: "` + providerValue + `"}`
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("provider credential in review comment should be reported: %#v", got)
}
}

View File

@@ -1,88 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"encoding/base64"
"net/url"
"strings"
)
func credentialValueHasStrongEvidence(key, value string) bool {
normalized := strings.TrimRight(strings.TrimSpace(value), ",;")
normalized = strings.TrimSpace(strings.Trim(normalized, `"'<>`))
candidates := credentialEvidenceCandidates(unwrapCredentialValue(normalized))
for _, candidate := range candidates {
if providerCredentialIdentifier(candidate) {
return true
}
}
if isCredentialMetadataField(key) {
return false
}
for _, candidate := range candidates {
if highEntropyCredentialValue(strings.ToLower(candidate)) || base64PaddedCredentialValue(candidate) {
return true
}
}
return percentEncodedCredentialValue(strings.ToLower(candidates[0])) ||
commandSubstitutionLooksCredentialLike(strings.ToLower(normalized))
}
func credentialEvidenceCandidates(value string) []string {
candidates := []string{value}
for range 3 {
decoded, err := url.PathUnescape(value)
if err != nil || decoded == value {
break
}
candidates = append(candidates, decoded)
value = decoded
}
return candidates
}
func isCredentialMetadataField(key string) bool {
if isBenignTokenField(key) {
return true
}
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
if len(parts) < 2 {
return false
}
switch parts[len(parts)-1] {
case "hash", "id", "kind", "marker", "prefix", "transport":
return true
default:
return false
}
}
func base64PaddedCredentialValue(value string) bool {
if len(value) < 16 || !strings.HasSuffix(value, "=") {
return false
}
if _, err := base64.StdEncoding.DecodeString(value); err != nil {
return false
}
return shannonEntropy(strings.TrimRight(value, "=")) >= 3.5
}
func percentEncodedCredentialValue(value string) bool {
if len(value) < 16 {
return false
}
var escapes int
for i := 0; i+2 < len(value); i++ {
if value[i] == '%' && isHexByte(value[i+1]) && isHexByte(value[i+2]) {
escapes++
i += 2
}
}
return escapes >= 2
}
func isHexByte(value byte) bool {
return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f')
}

View File

@@ -13,7 +13,7 @@ import (
)
var (
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*(?::=|[:=])\s*(?:!!str\s+)?(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\x60[^\x60]*\x60)|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\x60\s,}\]]+))`)
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
@@ -383,63 +383,33 @@ func anglePlaceholderIdentifier(value string) bool {
}
func credentialShapedValue(value string) bool {
normalized := strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'<>`))
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
return credentialShapedIdentifier(normalized)
}
func credentialShapedIdentifier(value string) bool {
return providerCredentialIdentifier(value)
}
func providerCredentialIdentifier(value string) bool {
value = strings.TrimSpace(value)
switch {
case providerTokenWithBody(value, "sk_live_", 16, ""),
providerTokenWithBody(value, "sk_test_", 16, ""),
providerTokenWithBody(value, "ghp_", 16, ""),
providerTokenWithBody(value, "gho_", 16, ""),
providerTokenWithBody(value, "ghu_", 16, ""),
providerTokenWithBody(value, "github_pat_", 16, "_"),
providerTokenWithBody(value, "xoxb_", 16, "-"),
providerTokenWithBody(value, "xoxp_", 16, "-"),
providerTokenWithBody(value, "xoxa_", 16, "-"),
providerTokenWithBody(value, "xoxb-", 16, "-"),
providerTokenWithBody(value, "xoxp-", 16, "-"),
providerTokenWithBody(value, "xoxa-", 16, "-"),
awsAccessKeyIdentifier(value):
case strings.HasPrefix(value, "sk_live_"),
strings.HasPrefix(value, "sk_test_"),
strings.HasPrefix(value, "ghp_"),
strings.HasPrefix(value, "gho_"),
strings.HasPrefix(value, "ghu_"),
strings.HasPrefix(value, "github_pat_"),
strings.HasPrefix(value, "xoxb_"),
strings.HasPrefix(value, "xoxp_"),
strings.HasPrefix(value, "xoxa_"):
return true
case strings.HasPrefix(value, "real-") &&
(strings.Contains(value, "secret") ||
strings.Contains(value, "token") ||
strings.Contains(value, "key") ||
strings.Contains(value, "password")):
return true
default:
return false
}
}
func providerTokenWithBody(value, prefix string, minBodyLength int, separators string) bool {
body, ok := strings.CutPrefix(value, prefix)
if !ok || len(body) < minBodyLength {
return false
}
for _, r := range body {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune(separators, r) {
continue
}
return false
}
return true
}
func awsAccessKeyIdentifier(value string) bool {
if len(value) != 20 || (!strings.HasPrefix(value, "AKIA") && !strings.HasPrefix(value, "ASIA")) {
return false
}
for _, r := range value[4:] {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
continue
}
return false
}
return true
}
func resourceTokenPlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
switch normalized {

View File

@@ -47,30 +47,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
inPrivateKey = false
}
for _, location := range credentialAssignmentRE.FindAllStringIndex(line, -1) {
rawMatch := line[location[0]:location[1]]
if !validCredentialAssignmentStart(line, location[0], rawMatch) {
continue
}
match := credentialAssignmentRE.FindStringSubmatch(rawMatch)
if !isCredentialAssignmentMatch(rawMatch) {
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
if !isCredentialAssignmentMatch(match[0]) {
continue
}
value := credentialAssignmentValue(match)
keyName, _ := normalizedCredentialAssignmentKey(rawMatch)
evidenceValue := value
if sourceCodeFile(file) {
if rhs, ok := sourceCodeTypedCredentialRHS(line, location[0], rawMatch); ok {
evidenceValue = rhs
}
}
if !(isWebhookCredentialKey(keyName) && webhookAssignmentValueLooksCredentialLike(value)) &&
!credentialValueHasStrongEvidence(keyName, evidenceValue) {
continue
}
keyName, _ := normalizedCredentialAssignmentKey(match[0])
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, line, location[0], rawMatch, value) ||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
isPlaceholderValue(value) ||
isPermissionScopeIdentifierAssignment(keyName, value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
@@ -79,7 +64,7 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
if looksLikeEqualityComparison(value) {
continue
}
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(rawMatch)))
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
}
for _, match := range jwtLikeRE.FindAllString(line, -1) {
if !isJWTToken(match) {
@@ -138,43 +123,21 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
return out
}
func validCredentialAssignmentStart(line string, start int, match string) bool {
if start <= 0 || credentialAssignmentOperator(match) != ":" {
return true
}
prefix := strings.TrimSpace(line[:start])
for _, arrow := range []string{"-->>", "->>", "-->", "->"} {
if strings.HasSuffix(prefix, arrow) {
return false
}
}
return true
}
func credentialAssignmentOperator(match string) string {
key, ok := credentialAssignmentKey(match)
if !ok {
return ""
}
rest := strings.TrimSpace(match[len(key):])
if strings.HasPrefix(rest, ":=") {
return ":="
}
if strings.HasPrefix(rest, ":") {
return ":"
}
if strings.HasPrefix(rest, "=") {
return "="
}
return ""
}
func isCredentialAssignmentMatch(match string) bool {
name, _, ok := normalizedCredentialAssignment(match)
name, value, ok := normalizedCredentialAssignment(match)
if !ok {
return false
}
return isExplicitCredentialKey(name) || isWebhookCredentialKey(name)
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
return true
}
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
return false
}
return isExplicitCredentialKey(name)
}
func normalizedCredentialAssignmentKey(match string) (string, bool) {
@@ -325,7 +288,7 @@ func tokenLikePlaceholderKey(key string) bool {
func tokenLikePlaceholderValue(key, value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || credentialShapedIdentifier(strings.Trim(value, `"'`)) {
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
if authCredentialTokenKey(key) {
@@ -360,8 +323,52 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
return stars >= 6 && alnum > 0
}
func isWeakTokenCredentialKey(key string) bool {
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
return false
}
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func isStrongTokenCredentialKey(key string) bool {
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "token"},
{"secret", "token"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
return false
}
func weakTokenValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
if normalized == "" ||
isNonSecretLiteralValue(value) ||
isPlaceholderValue(value) {
return false
}
candidate := unwrapCredentialValue(normalized)
return credentialShapedIdentifier(candidate) ||
highEntropyCredentialValue(candidate) ||
commandSubstitutionLooksCredentialLike(normalized) ||
(strings.Contains(normalized, "://") &&
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
}
func unwrapCredentialValue(value string) string {
value = strings.TrimSpace(strings.Trim(value, "\"'<>`"))
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
}
@@ -481,20 +488,17 @@ func numericStringPlaceholderValue(value string) bool {
return true
}
func isBenignCodeCredentialExpression(file, line string, matchStart int, match, value string) bool {
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) {
if !sourceCodeFile(file) || credentialShapedValue(value) {
return false
}
if rhs, ok := sourceCodeTypedCredentialRHS(line, matchStart, match); ok {
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
return isBenignTypedCredentialRHS(rhs)
}
if credentialShapedValue(value) {
return false
}
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
return true
@@ -514,16 +518,17 @@ func isBenignCodeCredentialExpression(file, line string, matchStart int, match,
return codeReferenceExpression(normalized)
}
func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (string, bool) {
if matchStart < 0 || matchStart+len(match) > len(line) || line[matchStart:matchStart+len(match)] != match {
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
idx := strings.Index(line, match)
if idx < 0 {
return "", false
}
key, ok := credentialAssignmentKey(match)
if !ok {
return "", false
}
rest := strings.TrimSpace(line[matchStart+len(key):])
if !strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, ":=") {
rest := strings.TrimSpace(line[idx+len(key):])
if !strings.HasPrefix(rest, ":") {
return "", false
}
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
@@ -531,12 +536,7 @@ func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (st
if assignmentIdx < 0 {
return "", false
}
rhs := strings.TrimSpace(typeAndRHS[assignmentIdx+1:])
parsed := credentialAssignmentRE.FindStringSubmatch("client_secret=" + rhs)
if parsed == nil {
return rhs, true
}
return credentialAssignmentValue(parsed), true
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
}
func isBenignTypedCredentialRHS(value string) bool {
@@ -568,7 +568,7 @@ func credentialAssignmentRawValueQuoted(match string) bool {
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
case ".go", ".js", ".jsx", ".py", ".sh", ".ts", ".tsx":
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
return true
default:
return false
@@ -593,7 +593,6 @@ func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
sourceCodeFakeOrPlaceholderLiteral(literal) ||
sourceCodeCredentialTermLiteral(literal) ||
sourceCodeCredentialPrefixLiteral(literal) ||
sourceCodeStringExpressionLiteral(literal) ||
sourceCodeVocabularyLiteral(literal) ||
sourceCodeSchemaTypeLiteral(literal) ||
benignCredentialStatusLiteral(literal)
@@ -686,18 +685,6 @@ func sourceCodeCredentialPrefixLiteral(value string) bool {
}
}
func sourceCodeStringExpressionLiteral(value string) bool {
normalized := strings.TrimSpace(value)
if normalized == "" ||
credentialShapedIdentifier(normalized) ||
highEntropyCredentialValue(strings.ToLower(normalized)) {
return false
}
return strings.Contains(normalized, "${") ||
strings.Contains(normalized, "$(") ||
(strings.Contains(normalized, `\b`) && strings.ContainsAny(normalized, "|[]{}()+*?"))
}
func sourceCodeVocabularyLiteral(value string) bool {
switch strings.ToLower(value) {
case "bot", "tenant", "user":
@@ -766,7 +753,7 @@ func codeIdentifier(value string) bool {
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[", `\`:
case "true", "false", "null", "nil", "{", "[":
return true
default:
return false
@@ -993,7 +980,6 @@ func credentialURLPasswordFixture(password string) bool {
normalized := strings.ToLower(strings.Trim(password, `"'`))
switch normalized {
case "p",
"p%40ss",
"pass",
"password",
"pat_abc",

View File

@@ -251,22 +251,26 @@ func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) {
}
}
func TestScanFileAllowsReadablePlaceholderMarkerSubstrings(t *testing.T) {
func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"API_KEY=notredactedreal",
"API_KEY=notplaceholdersecret",
"API_KEY=abcxxxxreal",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("readable credential words should not be findings: %#v", got)
count++
}
}
if count != 3 {
t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got)
}
}
func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA"
paddedTokenPrefix := "UTdrMm1O" + "OXBSNHZYOA"
paddedTokenPrefix := "YWJj" + "ZGVmZ2g"
paddedSecret := base64PaddedFixture(paddedSecretPrefix)
paddedToken := base64PaddedFixture(paddedTokenPrefix)
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
@@ -290,25 +294,17 @@ func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
}
}
func TestScanFileAllowsReadableBase64Lookalike(t *testing.T) {
got := ScanFile("docs/config.md", []byte("client_secret=placeholder=\n"))
if findingRules(got)["public_content_generic_credential"] {
t.Fatalf("readable base64 lookalike should not be a credential finding: %#v", got)
}
}
func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
jsonToken := providerValue
jsonSecret := providerValue
jsonKey := providerValue
jsonTenantToken := providerValue
jsonAppSecret := providerValue
jsonPrefixedKey := providerValue
jsonTenantCamelToken := providerValue
jsonGithubToken := providerValue
jsonVendorKey := providerValue
jsonSlackBotToken := "xoxb_" + "1234567890abcdef"
jsonToken := "real-json-token"
jsonSecret := "real " + "secret value"
jsonKey := "real-json-key"
jsonTenantToken := "real-tenant-json-token"
jsonAppSecret := "real-app-secret"
jsonPrefixedKey := "real-prefixed-key"
jsonTenantCamelToken := "real-tenant-camel-token"
jsonGithubToken := "real-github-token"
jsonVendorKey := "real-vendor-key"
jsonSlackBotToken := "xoxb-real-token"
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
`{"access_` + `token":"` + jsonToken + `"}`,
`{"client_` + `secret": "` + jsonSecret + `"}`,
@@ -338,13 +334,12 @@ func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
}
func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_OPENAI: " + providerValue,
"TOKEN_GITHUB: " + providerValue,
"CLIENT_SECRET_GOOGLE: " + providerValue,
"SECRET_KEY_BASE: " + providerValue,
"APP_PASSWORD_PROD: " + providerValue,
"API_KEY_OPENAI: real-openai-key",
"TOKEN_GITHUB: real-github-token",
"CLIENT_SECRET_GOOGLE: real-google-secret",
"SECRET_KEY_BASE: real-secret-key-base",
"APP_PASSWORD_PROD: real-prod-password",
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -352,7 +347,13 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
continue
}
count++
for _, forbidden := range []string{providerValue} {
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -363,77 +364,85 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
}
}
func TestScanFileAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_OPENAI: prod_key",
"CLIENT_SECRET_GOOGLE: prod_secret",
"TOKEN_GITHUB: github_token",
"APP_PASSWORD_PROD: prod_password",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
count++
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "stripe", text: "API_KEY: <" + stripeLike + ">", want: true},
{name: "github", text: "SECRET_TOKEN: <" + patLike + ">", want: true},
{name: "readable", text: "CLIENT_SECRET: <real-client-secret-value>", want: false},
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY: <" + stripeLike + ">",
"SECRET_TOKEN: <" + patLike + ">",
"CLIENT_SECRET: <real-client-secret-value>",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
})
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
}
}
func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "expiry provider token", text: `{"access_token_expires_in":"` + patLike + `"}`, want: true},
{name: "expiry provider secret", text: `{"refresh_token_expires_in":"` + stripeLike + `"}`, want: true},
{name: "status readable", text: `{"client_secret_status":"real-client-secret-value"}`, want: false},
{name: "name readable", text: `{"client_secret_name":"real-client-secret-value"}`, want: false},
{name: "app provider token", text: `{"app_token":"` + patLike + `"}`, want: true},
{name: "sync provider secret", text: `{"sync_token":"` + stripeLike + `"}`, want: true},
{name: "target readable", text: `{"target_token":"real-client-secret-value"}`, want: false},
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
`{"access_token_expires_in":"` + patLike + `"}`,
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
`{"client_secret_status":"real-client-secret-value"}`,
`{"client_secret_name":"real-client-secret-value"}`,
`{"app_token":"` + patLike + `"}`,
`{"sync_token":"` + stripeLike + `"}`,
`{"target_token":"real-client-secret-value"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/public.json", tc.text, tc.want)
})
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
}
}
func TestScanFileAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_NAME: prod_key",
"CLIENT_SECRET_NAME: prod_secret",
"SECRET_STATUS: prod_secret",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
count++
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestScanFileDetectsAccessKeyCredentials(t *testing.T) {
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY_ID: " + accessKey,
@@ -584,18 +593,18 @@ func TestScanFileAllowsCredentialReferenceValues(t *testing.T) {
func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
cases := []struct {
name string
text string
want bool
}{
{name: "provider", text: "API_KEY=${{" + stripeLike + "}}", want: true},
{name: "readable", text: "TOKEN=${{real-secret-token-value}}", want: false},
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY=${{" + stripeLike + "}}",
"TOKEN=${{real-secret-token-value}}",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
})
if count != 2 {
t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got)
}
}
@@ -639,7 +648,6 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@proxy:8080"`,
`proxy := "http://user:p%40ss@proxy:8080/path"`,
`repo := "https://u:t@h/r.git"`,
`target := "https://attacker:pw@open.feishu.cn"`,
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
@@ -813,151 +821,35 @@ func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *tes
}
}
func TestScanFileAllowsStrongAuthTokenKeysWithoutStrongValueEvidence(t *testing.T) {
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"access_token":"img_abc123"}`,
`{"api_token":"img_live_secret"}`,
`{"service_token":"ab********cd"}`,
`{"bot_token":"board_v3_example"}`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("token field names alone should not produce findings: %#v", got)
}
}
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(strings.Join([]string{
`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`,
`cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}`,
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600)`,
`rt := &stubRoundTripper{respBody: ` + "`" + `{"access_token":"t","token_type":"Bearer"}` + "`" + `}`,
`envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"`,
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_auto\nFEISHU_APP_SECRET=auto_secret\n"), 0600)`,
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600)`,
`if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" {`,
`if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" {`,
`if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" {`,
`return &core.CliConfig{AppID: "dummy", AppSecret: "dummy"}`,
`os.WriteFile(path, []byte("API_KEY=replace-me\n"), 0600)`,
`body := "APP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
}
}
}
func TestScanFileAllowsCredentialIdentifierFields(t *testing.T) {
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
`"api_key_id": "k1",`,
`"secret_id": "s1",`,
`"token_id": "t1",`,
`"private_key_id": "pk1",`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("credential identifier fields should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsCredentialShapedIdentifierFieldValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
`"api_key_id": "` + stripeLike + `",`,
`"token_id": "` + githubToken + `",`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 2 {
t.Fatalf("credential-shaped identifier field findings = %d, want 2: %#v", count, got)
if count != 4 {
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
}
}
func TestCredentialShapedValueTrimsWhitespaceBeforeDelimiters(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
if !credentialShapedValue(` "` + providerValue + `" `) {
t.Fatal("space-padded quoted provider credential should be recognized")
}
}
func TestScanFileDetectsProviderCredentialsAcrossAssignmentSyntaxes(t *testing.T) {
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
tests := []struct {
name string
path string
text string
}{
{name: "Go raw string", path: "pkg/config.go", text: "const clientSecret = `" + providerValue + "`"},
{name: "TypeScript template literal", path: "pkg/config.ts", text: "const clientSecret = `" + providerValue + "`;"},
{name: "shell backtick", path: "scripts/config.sh", text: "client_secret=`" + providerValue + "`"},
{name: "YAML string tag", path: "docs/config.yaml", text: "client_secret: !!str " + providerValue},
{name: "YAML string tag double quoted", path: "docs/config.yaml", text: `client_secret: !!str "` + providerValue + `"`},
{name: "YAML string tag single quoted", path: "docs/config.yaml", text: `client_secret: !!str '` + providerValue + `'`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScanFile(tt.path, []byte(tt.text+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("provider credential should be reported: %#v", got)
}
})
}
}
func TestScanFileDetectsPercentEncodedProviderCredential(t *testing.T) {
providerBody := strings.Join([]string{"1234567890abcdef", "1234567890abcdef", "1234"}, "")
tests := []string{
"access_token: ghp%" + "5F" + providerBody,
"access_token_hash: ghp%" + "255F" + providerBody,
}
for _, text := range tests {
got := ScanFile("docs/config.yaml", []byte(text+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("percent-encoded provider credential should be reported: %#v", got)
}
}
}
func TestScanFileRequiresCompleteProviderCredentialFormats(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"token_type: asian",
"token_prefix: ASIA",
"token_prefix: ghp_",
"api_key: sk_live_example",
"token_prefix: asianmarketsegment01",
"token_prefix: ghp_placeholder_value",
}, "\n")+"\n"))
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("incomplete provider prefixes should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsEncodedTokenMetadataURL(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte("token_url: https%3A%2F%2Fexample.invalid/oauth/token\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("encoded token metadata URL should not be credential finding: %#v", got)
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
}
}
}
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
got := ScanFile("fixtures/minutes_detail.go", []byte(strings.Join([]string{
"var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)",
"REALISTIC_TOKEN_RE=\"\\\"${TOKEN_BODY}\\\"|\\`${TOKEN_BODY}\\`|\\\\b${TOKEN_BODY}\\\\b\"",
}, "\n")+"\n"))
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
@@ -1035,22 +927,6 @@ func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
}
}
func TestScanFileAllowsSourceCodeSyntheticCredentialIdentifiers(t *testing.T) {
got := ScanFile("fixtures/sheets_media.go", []byte(strings.Join([]string{
`const fakeOfficeTokenPrefix = "fake_office_"`,
`const localOfficeTokenPrefix = "local_office_"`,
`const imageLiveSecretMarker = "img_live_secret"`,
`const imageProdKeyMarker = "img_prod_key"`,
`if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {`,
`if strings.HasPrefix(spreadsheetToken, localOfficeTokenPrefix) {`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("source code token prefix references should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`app_secret=***`,
@@ -1065,18 +941,22 @@ func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
}
}
func TestScanFileAllowsPartiallyMaskedCredentialValues(t *testing.T) {
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
"client_secret=realprefix***realsuffix",
"client_secret=ab********cd",
"access_token=ab********cd",
"refresh_token=realprefix********realsuffix",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("partially masked values should not be credential findings: %#v", got)
count++
}
}
if count != 4 {
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
@@ -1092,7 +972,6 @@ func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
}
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
file string
@@ -1101,47 +980,32 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
{
name: "typescript simple secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `"`,
text: `const clientSecret: string = "real-client-secret-value"`,
},
{
name: "typescript terminated secret",
name: "typescript numeric password",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `";`,
},
{
name: "typescript secret with trailing comment",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `"; // production`,
},
{
name: "typescript asserted secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `" as const;`,
},
{
name: "typescript provider password",
file: "fixtures/source_secret.ts",
text: `const password: string = "` + providerValue + `"`,
text: `const password: string = "12345678901234567890"`,
},
{
name: "typescript union secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string | undefined = "` + providerValue + `"`,
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
},
{
name: "python simple secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str = "` + providerValue + `"`,
text: `self.client_secret: str = "real-client-secret-value"`,
},
{
name: "python union secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str | None = "` + providerValue + `"`,
text: `self.client_secret: str | None = "real-client-secret-value"`,
},
{
name: "python optional secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: Optional[str] = "` + providerValue + `"`,
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
},
}
for _, tc := range cases {
@@ -1154,154 +1018,24 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
}
}
func TestScanFileDetectsRepeatedTypedCredentialAssignments(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "placeholder";`, false)
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "`+providerValue+`";`, true)
got := ScanFile("fixtures/source_secret.ts", []byte(
`const clientSecret: string = "placeholder"; const clientSecret: string = "`+providerValue+`";`+"\n",
))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 1 {
t.Fatalf("repeated typed credential findings = %d, want 1: %#v", count, got)
}
}
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "stripe", text: `const ClientSecret = "` + stripeLike + `"`, want: true},
{name: "github", text: `const GithubToken = "` + githubToken + `"`, want: true},
{name: "password number", text: `const Password = "12345678901234567890"`, want: false},
{name: "secret number", text: `const ClientSecretNumber = "12345678901234567890"`, want: false},
{name: "format literal", text: `const ClientSecretFormat = "abc%sdefreal"`, want: false},
{name: "inline format literal", text: `fmt.Println("done"); const ClientSecret = "abc%sdefreal"`, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "fixtures/source_secret.go", tc.text, tc.want)
})
}
}
func TestScanFileDetectsGoShortDeclarationCredentials(t *testing.T) {
providerSecret := "sk_" + "live_1234567890abcdef"
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
`clientSecret := "` + providerSecret + `"`,
`accessToken := "` + providerToken + `"`,
`const ClientSecret = "real-client-secret-value"`,
`const GithubToken = "` + githubToken + `"`,
`const Password = "12345678901234567890"`,
`const ClientSecretNumber = "12345678901234567890"`,
`const ClientSecretFormat = "abc%sdefreal"`,
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 2 {
t.Fatalf("Go short declaration credential findings = %d, want 2: %#v", count, got)
}
}
func TestGenericCredentialDecisionMatrix(t *testing.T) {
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
tokenHash := "6f1ed002ab559585" + "9014ebf0951522d9" +
"a0e3c1f4206254d" + "28a13efbbc8d56a30"
tests := []struct {
name string
path string
text string
comment bool
want bool
}{
{name: "source synthetic token prefix", path: "pkg/sheets.go", text: `const localOfficeTokenPrefix = "local_office_"`, want: false},
{name: "source token kind state", path: "pkg/client.py", text: `self._token_kind: TokenKind | None = None`, want: false},
{name: "documentation token prefix", path: "docs/config.yaml", text: `token_prefix: local_office_`, want: false},
{name: "documentation token kind", path: "docs/config.yaml", text: `token_kind: bearer`, want: false},
{name: "documentation token hash", path: "docs/config.yaml", text: `access_token_hash: ` + tokenHash, want: false},
{name: "comment fixture placeholder", text: `AppSecret: "fake-secret"`, comment: true, want: false},
{name: "test fixture placeholder", path: "pkg/config_test.go", text: `AppSecret: "fake-secret"`, want: false},
{name: "test real-labeled token", path: "pkg/config_test.go", text: `token: "real-tenant-access-token"`, want: false},
{name: "test ambiguous concrete secret word", path: "pkg/config_test.go", text: `AppSecret: "supersecret"`, want: false},
{name: "resource token placeholder", path: "docs/images.md", text: `"token": "img_abc123"`, want: false},
{name: "partially masked token", path: "docs/auth.md", text: `token=ab********cd`, want: false},
{name: "source readable secret words", path: "pkg/config.go", text: `const AppSecret = "customer-prod-secret"`, want: false},
{name: "documentation readable secret words", path: "docs/config.yaml", text: `client_secret: customer-prod-secret`, want: false},
{name: "comment middle fixture marker", text: `API_KEY=prod-fake-key`, comment: true, want: false},
{name: "comment negated fixture marker", text: `AppSecret: "not-fake-secret"`, comment: true, want: false},
{name: "source with credential words", path: "pkg/config.go", text: `secretWithPassword := "hunter2"`, want: false},
{name: "production filename containing sample", path: "pkg/sampler.go", text: `clientSecret := "customer-prod-secret"`, want: false},
{name: "provider token under weak key", path: "docs/config.yaml", text: `token: ` + providerToken, want: true},
{name: "provider token under hash key", path: "docs/config.yaml", text: `access_token_hash: ` + providerToken, want: true},
{name: "high entropy strong secret", path: "docs/config.yaml", text: `client_secret: ` + highEntropyValue, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got []Finding
if tt.comment {
got = ScanComment("issue_comment", tt.text)
} else {
got = ScanFile(tt.path, []byte(tt.text+"\n"))
}
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
}
})
}
}
func TestScanFileClassifiesLowEvidenceTestFixtureCredentials(t *testing.T) {
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
tests := []struct {
name string
value string
want bool
}{
{name: "human readable access token", value: "user-access-token", want: false},
{name: "delimited secret value", value: "secret-value", want: false},
{name: "underscored secret fixture", value: "plain_secret", want: false},
{name: "short delimited fixture", value: "t-abc", want: false},
{name: "embedded test marker", value: "perm-grant-test-secret-skip", want: false},
{name: "real labeled fixture", value: "real-token", want: false},
{name: "ambiguous concrete word", value: "supersecret", want: false},
{name: "provider token", value: providerToken, want: true},
{name: "high entropy secret", value: highEntropyValue, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScanFile("pkg/config_test.go", []byte(`AppSecret: "`+tt.value+`"`+"\n"))
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
}
})
}
}
func TestScanFileAllowsLowEvidenceTestFixtureAssignmentSyntaxes(t *testing.T) {
got := ScanFile("pkg/config_test.go", []byte(strings.Join([]string{
`secret := "secret-value"`,
`samplePassword := "sample-password"`,
`bodyWithToken := "plain text body\\nDownload: https://example.com/file?token=tok_aaa\\n"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("low-evidence test fixture assignment should not be reported: %#v", got)
}
if count != 6 {
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
}
}
@@ -1382,10 +1116,9 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"` + stripeLike + `"}`,
`{"client_token":"` + githubToken + `"}`,
`{"client_token":"real-client-secret-value"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -1419,10 +1152,9 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`{ "resource_token": "` + stripeLike + `" }`,
`{ "block_token": "` + githubToken + `" }`,
`{ "block_token": "real-client-secret-value" }`,
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -1636,43 +1368,39 @@ func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
}
}
func TestScanFileAllowsInvalidProviderPlaceholderLookalikes(t *testing.T) {
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"client_secret: " + stripeLike + "_HERE",
"api_key: YOUR_" + stripeLike,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("invalid provider placeholder lookalike should not be blocked: %#v", got)
count++
}
}
if count != 2 {
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
}
}
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "stripe", text: "CLIENT_SECRET=%" + stripeLike + "%", want: true},
{name: "github", text: "GITHUB_TOKEN=%" + patLike + "%", want: true},
{name: "readable", text: "TOKEN=%real-secret-token-value%", want: false},
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"CLIENT_SECRET=%" + stripeLike + "%",
"GITHUB_TOKEN=%" + patLike + "%",
"TOKEN=%real-secret-token-value%",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/config.md", tc.text, tc.want)
})
}
}
func assertGenericCredentialFinding(t *testing.T, file, text string, want bool) {
t.Helper()
got := ScanFile(file, []byte(text+"\n"))
if actual := findingRules(got)["public_content_generic_credential"]; actual != want {
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, want, got)
if count != 3 {
t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got)
}
}

View File

@@ -16,7 +16,6 @@ import (
"strings"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
@@ -338,7 +337,7 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
case name == "open_id" || hasPlaceholderToken(tokens, "user", "owner", "participant", "approver", "speaker"):
return "ou_test123", true
case hasPlaceholderToken(tokens, "department", "dept"):
return "od-test123", true
return "od_test123", true
case hasPlaceholderToken(tokens, "message"):
return "om_test123", true
case name == "file_key":
@@ -727,11 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) {
return nil, fmt.Errorf("not a lark-cli command")
}
argv = truncateShellTail(argv)
var jqValid bool
argv, jqValid = stripDryRunJQFilter(argv)
if jqValid {
argv = forceDryRunJSONFormat(argv)
}
argv = forceDryRunJSONFormat(argv)
hasDryRunArg := false
dryRunEnabled := false
for _, arg := range argv[1:] {
@@ -780,73 +775,6 @@ func truncateShellTail(argv []string) []string {
return argv
}
// stripDryRunJQFilter removes valid output-only jq filters from the synthetic
// dry-run invocation. Invalid jq syntax and incompatible output flags are left
// untouched so the real CLI execution still rejects the documented command.
// The bool reports whether other output normalization remains safe.
func stripDryRunJQFilter(argv []string) ([]string, bool) {
jqExpr, outputPath, format, hasJQ, jqHasValue := dryRunOutputFlags(argv)
if !hasJQ {
return argv, true
}
if !jqHasValue || output.ValidateJqFlags(jqExpr, outputPath, format) != nil {
return argv, false
}
out := make([]string, 0, len(argv))
for i := 0; i < len(argv); i++ {
arg := argv[i]
switch {
case arg == "--":
return append(out, argv[i:]...), true
case arg == "--jq" || arg == "-q":
i++
case strings.HasPrefix(arg, "--jq=") || strings.HasPrefix(arg, "-q="):
continue
default:
out = append(out, arg)
}
}
return out, true
}
func dryRunOutputFlags(argv []string) (jqExpr, outputPath, format string, hasJQ, jqHasValue bool) {
for i := 1; i < len(argv); i++ {
arg := argv[i]
if arg == "--" {
break
}
switch {
case arg == "--jq" || arg == "-q":
hasJQ = true
jqHasValue = i+1 < len(argv)
if jqHasValue {
jqExpr = argv[i+1]
i++
}
case strings.HasPrefix(arg, "--jq=") || strings.HasPrefix(arg, "-q="):
hasJQ = true
jqHasValue = true
jqExpr = arg[strings.IndexByte(arg, '=')+1:]
case arg == "--output":
if i+1 < len(argv) {
outputPath = argv[i+1]
i++
}
case strings.HasPrefix(arg, "--output="):
outputPath = strings.TrimPrefix(arg, "--output=")
case arg == "--format":
if i+1 < len(argv) {
format = argv[i+1]
i++
}
case strings.HasPrefix(arg, "--format="):
format = strings.TrimPrefix(arg, "--format=")
}
}
return jqExpr, outputPath, format, hasJQ, jqHasValue
}
func dryRunFlagExplicitlyTrue(arg string) bool {
value, ok := strings.CutPrefix(arg, "--dry-run=")
if !ok {
@@ -950,23 +878,16 @@ func extractDryRunJSON(raw []byte) (facts.DryRunRequest, int, error) {
var firstErr error
for start >= 0 {
var preview struct {
API []facts.DryRunRequest `json:"api"`
Data struct {
API []facts.DryRunRequest `json:"api"`
} `json:"data"`
API []facts.DryRunRequest `json:"api"`
}
dec := json.NewDecoder(bytes.NewReader(raw[start:]))
if err := dec.Decode(&preview); err == nil {
api := preview.API
if len(api) == 0 {
api = preview.Data.API
}
if len(api) == 0 {
if len(preview.API) == 0 {
if firstErr == nil {
firstErr = errNoDryRunAPI
}
} else {
return api[0], len(api), nil
return preview.API[0], len(preview.API), nil
}
} else if firstErr == nil {
firstErr = err

View File

@@ -33,17 +33,6 @@ func TestExtractDryRunJSONSkipsBanner(t *testing.T) {
}
}
func TestExtractDryRunJSONReadsSuccessEnvelope(t *testing.T) {
raw := `{"ok":true,"dry_run":true,"data":{"api":[{"method":"GET","url":"/open-apis/test"}]}}`
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
if err != nil {
t.Fatalf("extractDryRunJSON() error = %v", err)
}
if got.Method != "GET" || got.URL != "/open-apis/test" || apiCallCount != 1 {
t.Fatalf("got request=%#v apiCallCount=%d, want enveloped GET and count 1", got, apiCallCount)
}
}
func TestExtractDryRunJSONSkipsBannerWithBraces(t *testing.T) {
raw := "banner {not json}\n{\"api\":[{\"method\":\"GET\",\"url\":\"/open-apis/test\"}]}\n"
got, apiCallCount, err := extractDryRunJSON([]byte(raw))
@@ -194,38 +183,6 @@ func TestRunDryRunsIgnoresTrailingShellComment(t *testing.T) {
}
}
func TestRunDryRunsIgnoresJQFilterWhenValidatingRequestPreview(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/flags"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "im +flag-list",
Runnable: true,
Identities: []string{"user"},
Flags: []manifest.Flag{
{Name: "as", TakesValue: true},
{Name: "page-all"},
{Name: "jq", Shorthand: "q", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli im +flag-list --as user --page-all -q '.data.flag_items[-1]'`,
SourceFile: "skills/lark-im/references/lark-im-flag-list.md",
Line: 26,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("jq example should remain executable: %#v", facts)
}
wantArgs := []string{"im", "+flag-list", "--as", "user", "--page-all", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesPlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/messages","params":{"chat_id":"oc_test123","page_token":"page_test123"}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
@@ -348,13 +305,6 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
}
}
func TestFakeValueFromPlaceholderNameUsesOpenDepartmentPrefix(t *testing.T) {
got, ok := fakeValueFromPlaceholderName("open_department_id")
if !ok || got != "od-test123" {
t.Fatalf("open_department_id placeholder = %q, %v; want od-test123, true", got, ok)
}
}
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
@@ -827,72 +777,6 @@ func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
}
}
func TestAppendDryRunArgRemovesJQFilter(t *testing.T) {
tests := []struct {
name string
raw string
want []string
}{
{
name: "short split",
raw: `lark-cli im +flag-list --page-all -q '.data.flag_items[-1]'`,
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
},
{
name: "long split",
raw: `lark-cli im +flag-list --jq '.data.flag_items[].item_id' --page-all`,
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
},
{
name: "short inline",
raw: `lark-cli im +flag-list -q='.data.flag_items[-1]' --page-all`,
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
},
{
name: "long inline",
raw: `lark-cli im +flag-list --jq='.data.flag_items[-1]' --page-all`,
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
},
{
name: "missing value remains invalid",
raw: `lark-cli im +flag-list --page-all --jq`,
want: []string{"im", "+flag-list", "--page-all", "--jq", "--dry-run"},
},
{
name: "next flag is not accepted as jq expression",
raw: `lark-cli im +flag-list --jq --page-all`,
want: []string{"im", "+flag-list", "--jq", "--page-all", "--dry-run"},
},
{
name: "invalid expression remains invalid",
raw: `lark-cli im +flag-list --jq 'invalid[' --page-all`,
want: []string{"im", "+flag-list", "--jq", "invalid[", "--page-all", "--dry-run"},
},
{
name: "incompatible pretty format remains invalid",
raw: `lark-cli im +flag-list --jq '.data' --format pretty`,
want: []string{"im", "+flag-list", "--jq", ".data", "--format", "pretty", "--dry-run"},
},
{
name: "compatible json format preserves request preview",
raw: `lark-cli im +flag-list --jq '.data' --format json`,
want: []string{"im", "+flag-list", "--format", "json", "--dry-run"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := appendDryRunArg(tt.raw)
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
for _, raw := range []string{
"lark-cli mail +watch --format data --dry-run",

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -14,7 +15,6 @@ import (
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/testutil/gitcmd"
"github.com/larksuite/cli/internal/vfs"
)
@@ -203,8 +203,7 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
t.Fatal(err)
}
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
publicDoc := "api_" + "key = \"" + providerValue + "\"\n" +
publicDoc := "api_" + "key = \"example-public-key\"\n" +
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
t.Fatal(err)
@@ -600,8 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
commandArgs := append([]string{"-c", "core.hooksPath=/dev/null"}, args...)
cmd := gitcmd.Command(repo, commandArgs...)
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
out, err := cmd.CombinedOutput()
if err != nil {

View File

@@ -101,7 +101,6 @@ func TestSelectRecommendedScope_Empty(t *testing.T) {
}
func TestComputeMinimumScopeSet(t *testing.T) {
ensureFreshRegistry(t)
minSet := ComputeMinimumScopeSet("user")
if len(minSet) == 0 {
if len(ListFromMetaProjects()) == 0 {
@@ -249,18 +248,10 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
// recommend.allow special-cases scopes absent from scope_priorities.json
// (application v7 is not in the platform catalog yet) so interactive
// login's "common scopes" tier still offers them. Only the read scope is
// admitted: write stays out of the recommended tier by design.
if !allowSet["application:app_slash_command:read"] {
t.Error("expected application:app_slash_command:read in override allow set")
}
if allowSet["application:app_slash_command:write"] {
t.Error("write scope must NOT be in the recommended tier")
}
if len(allowSet) != 1 {
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
// recommend.allow in scope_overrides.json is intentionally empty:
// no scopes are special-cased into the auto-approve set anymore.
if len(allowSet) != 0 {
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
}
}

Some files were not shown because too many files have changed in this diff Show More