Compare commits

..

1 Commits

Author SHA1 Message Date
zhanghuanxu
dae3e5501d docs(slides): document table dimensions 2026-07-16 21:54:41 +08:00
939 changed files with 4838 additions and 59093 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
@@ -302,11 +295,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 +308,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 +341,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 +361,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 +395,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
@@ -544,8 +472,8 @@ jobs:
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.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
#
# plugin-integration and sidecar-integration are intentionally NOT
# in this loop yet: they run on every PR and their status is shown

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");

File diff suppressed because one or more lines are too long

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

View File

@@ -2,201 +2,6 @@
All notable changes to this project will be documented in this file.
## [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
@@ -1722,14 +1527,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[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

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 sidecar-test
all: test
@@ -51,18 +51,13 @@ 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:

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

@@ -344,18 +344,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

@@ -352,9 +352,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 +371,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")
}
}

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"})
@@ -181,60 +158,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

@@ -66,7 +66,6 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
}
}
appendSlidesXMLQuickReference(&b, cmd, skillFS)
cmd.Long = b.String()
return true
}
@@ -117,90 +116,6 @@ const (
shortcutBaseAnnotation = "affordance-shortcut-base"
)
const slidesXMLQuickReferencePath = "lark-slides/references/xml-schema-quick-ref.md"
// slidesShortcutReferencePaths maps each Slides shortcut to its primary
// command guide. XML-consuming shortcuts also include the shared schema
// reference because their command guide accepts XML but does not repeat the
// complete element grammar.
var slidesShortcutReferencePaths = map[string][]string{
"+create": {
"lark-slides/references/lark-slides-create.md",
slidesXMLQuickReferencePath,
},
"+xml-get": {
"lark-slides/references/lark-slides-xml-presentations-get.md",
},
"+screenshot": {
"lark-slides/references/lark-slides-screenshot.md",
},
"+media-upload": {
"lark-slides/references/lark-slides-media-upload.md",
},
"+replace-slide": {
"lark-slides/references/lark-slides-replace-slide.md",
"lark-slides/references/lark-slides-edit-workflows.md",
slidesXMLQuickReferencePath,
},
"+replace-pages": {
"lark-slides/references/lark-slides-replace-pages.md",
"lark-slides/references/lark-slides-edit-workflows.md",
slidesXMLQuickReferencePath,
},
"+history-list": {
"lark-slides/references/lark-slides-history.md",
},
"+history-revert": {
"lark-slides/references/lark-slides-history.md",
},
"+history-revert-status": {
"lark-slides/references/lark-slides-history.md",
},
}
// appendSlidesXMLQuickReference adds the embedded XML schema summary to the
// slides domain help. The reference file is already shipped in the skill
// content tree, so help and the standalone skill reader share one source of
// truth instead of maintaining a second, drifting copy in Go.
func appendSlidesXMLQuickReference(b *strings.Builder, cmd *cobra.Command, skillFS fs.FS) {
if cmd.Name() != "slides" || skillFS == nil {
return
}
content, err := fs.ReadFile(skillFS, slidesXMLQuickReferencePath)
if err != nil || len(content) == 0 {
return
}
b.WriteString("\n\nEmbedded XML syntax quick reference:\n")
b.Write(content)
}
func readSlidesShortcutReferences(cmd *cobra.Command, skillFS fs.FS) ([]string, bool) {
if cmdmeta.Domain(cmd) != "slides" || skillFS == nil {
return nil, false
}
paths, ok := slidesShortcutReferencePaths[cmd.Name()]
if !ok {
return nil, false
}
var contents []string
for _, path := range paths {
content, err := fs.ReadFile(skillFS, path)
if err != nil || len(content) == 0 {
continue
}
contents = append(contents, fmt.Sprintf("Embedded command reference: %s\n%s", path, content))
}
return contents, len(contents) > 0
}
func appendSlidesShortcutReferences(b *strings.Builder, contents []string) {
for _, content := range contents {
b.WriteString("\n\n")
b.WriteString(content)
}
}
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
// few strings is the only build-time cost; the overlay stays untouched).
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
@@ -256,11 +171,11 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
}
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
// overlay and any embedded command references — the same top layout as method
// help (description, Risk, guidance block, related skills) minus the schema
// pointer, which shortcuts have none of. Returns false when the command is not
// a shortcut, or when it has neither an overlay nor an embedded reference, so
// ordinary shortcuts keep the default help plus the bottom risk/tips append.
// overlay — the same top layout as method help (description, Risk, guidance
// block, related skills) minus the schema pointer, which shortcuts have none
// of. Returns false when the command is not a shortcut or carries no overlay
// entry, so shortcuts without guidance keep the default help plus the bottom
// risk/tips append.
//
// The lead is the command's pristine base (captureHelpBase): a shortcut that
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
@@ -276,17 +191,12 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
return false
}
references, hasReferences := readSlidesShortcutReferences(cmd, skillFS)
var a meta.Affordance
hasAffordance := false
if raw, ok := affordanceRaw(cmd); ok {
if parsed, parsedOK := (meta.Method{Affordance: raw}).ParsedAffordance(); parsedOK {
a = parsed
hasAffordance = true
}
raw, ok := affordanceRaw(cmd)
if !ok {
return false
}
if !hasAffordance && !hasReferences {
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
if !ok {
return false
}
if len(a.Tips) == 0 {
@@ -301,7 +211,6 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
b.WriteString(block)
}
writeRelatedSkills(&b, a.Skills, skillFS)
appendSlidesShortcutReferences(&b, references)
cmd.Long = b.String()
return true

View File

@@ -264,94 +264,6 @@ func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
}
}
func TestPrepareShortcutHelp_SlidesReferenceWithoutAffordance(t *testing.T) {
sc := &cobra.Command{Use: "+xml-get", Short: "Fetch presentation XML"}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetDomain(sc, "slides")
cmdmeta.SetAffordanceRef(sc, "slides", "+xml-get")
cmdutil.SetRisk(sc, "read")
skillFS := fstest.MapFS{
"lark-slides/references/lark-slides-xml-presentations-get.md": {
Data: []byte("# slides +xml-get\n\nRead the presentation XML."),
},
}
if !PrepareShortcutHelp(sc, skillFS) {
t.Fatal("PrepareShortcutHelp returned false for a Slides shortcut with an embedded reference")
}
for _, want := range []string{
"Fetch presentation XML",
"Risk: read",
"Embedded command reference: lark-slides/references/lark-slides-xml-presentations-get.md",
"Read the presentation XML.",
} {
if !strings.Contains(sc.Long, want) {
t.Errorf("Slides shortcut help missing %q:\n%s", want, sc.Long)
}
}
PrepareShortcutHelp(sc, skillFS)
if got := strings.Count(sc.Long, "Embedded command reference:"); got != 1 {
t.Fatalf("embedded reference appended %d times after re-render, want 1:\n%s", got, sc.Long)
}
}
func TestSlidesShortcutReferenceMapping(t *testing.T) {
want := map[string]string{
"+create": "lark-slides/references/lark-slides-create.md",
"+xml-get": "lark-slides/references/lark-slides-xml-presentations-get.md",
"+screenshot": "lark-slides/references/lark-slides-screenshot.md",
"+media-upload": "lark-slides/references/lark-slides-media-upload.md",
"+replace-slide": "lark-slides/references/lark-slides-replace-slide.md",
"+replace-pages": "lark-slides/references/lark-slides-replace-pages.md",
"+history-list": "lark-slides/references/lark-slides-history.md",
"+history-revert": "lark-slides/references/lark-slides-history.md",
"+history-revert-status": "lark-slides/references/lark-slides-history.md",
}
for command, path := range want {
t.Run(command, func(t *testing.T) {
sc := &cobra.Command{Use: command, Short: command}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetDomain(sc, "slides")
skillFS := fstest.MapFS{
path: {Data: []byte("reference content")},
}
contents, ok := readSlidesShortcutReferences(sc, skillFS)
if !ok || len(contents) == 0 {
t.Fatalf("shortcut %q has no mapped reference", command)
}
if !strings.Contains(contents[0], "Embedded command reference: "+path) {
t.Fatalf("shortcut %q mapped content does not include %q:\n%s", command, path, contents[0])
}
})
}
}
func TestSlidesScreenshotHelpDoesNotIncludeXMLQuickReference(t *testing.T) {
sc := &cobra.Command{Use: "+screenshot", Short: "Save screenshots"}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetDomain(sc, "slides")
skillFS := fstest.MapFS{
"lark-slides/references/lark-slides-screenshot.md": {
Data: []byte("# slides +screenshot\n\nSave screenshots."),
},
slidesXMLQuickReferencePath: {
Data: []byte("# XML Schema Quick Reference"),
},
}
contents, ok := readSlidesShortcutReferences(sc, skillFS)
if !ok {
t.Fatal("screenshot shortcut should have a primary reference")
}
if len(contents) != 1 {
t.Fatalf("screenshot reference count = %d, want 1: %#v", len(contents), contents)
}
if strings.Contains(contents[0], "XML Schema Quick Reference") {
t.Fatalf("screenshot help must not include the XML quick reference:\n%s", contents[0])
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {
@@ -394,51 +306,3 @@ func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
}
}
func TestPrepareDomainHelp_SlidesIncludesEmbeddedXMLReference(t *testing.T) {
root := &cobra.Command{Use: "root"}
dom := &cobra.Command{Use: "slides", Short: "Slides"}
cmdmeta.SetDomain(dom, "slides")
dom.AddCommand(&cobra.Command{Use: "+create", Short: "Create", Run: func(*cobra.Command, []string) {}})
root.AddCommand(dom)
const quickReference = `# XML Schema Quick Reference
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide>
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title"><p>Title</p></content>
</shape>
</data>
</slide>
</presentation>
<table><colgroup><col/></colgroup><tr><td><content><p>A</p></content></td></tr></table>
<chart><chartPlotArea/><chartData/></chart>`
skillFS := fstest.MapFS{
"lark-slides/SKILL.md": {Data: []byte("# slides")},
"lark-slides/references/xml-schema-quick-ref.md": {Data: []byte(quickReference)},
}
if !PrepareDomainHelp(dom, skillFS) {
t.Fatal("PrepareDomainHelp returned false for slides domain")
}
for _, want := range []string{
"Embedded XML syntax quick reference:",
`<presentation xmlns="http://www.larkoffice.com/sml/2.0"`,
"<shape type=\"text\"",
"<content",
"topLeftX",
"<table>",
"<chart>",
} {
if !strings.Contains(dom.Long, want) {
t.Errorf("slides help missing XML reference marker %q:\n%s", want, dom.Long)
}
}
PrepareDomainHelp(dom, skillFS)
if got := strings.Count(dom.Long, "Embedded XML syntax quick reference:"); got != 1 {
t.Fatalf("slides XML reference appended %d times after re-render, want 1:\n%s", got, dom.Long)
}
}

View File

@@ -707,18 +707,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

@@ -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

@@ -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

@@ -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

@@ -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,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

@@ -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

@@ -34,17 +34,27 @@ 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,
DryRun: opts.DryRun,
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

@@ -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

@@ -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"
@@ -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 {

View File

@@ -194,38 +194,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{{
@@ -827,72 +795,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 {

View File

@@ -1,72 +0,0 @@
{
"version": "0.0.1",
"services": [
{
"name": "calendar",
"version": "v4",
"title": "Calendar API",
"servicePath": "/open-apis/calendar/v4",
"resources": {
"events": {
"methods": {
"create": {
"path": "calendars/{calendar_id}/events",
"httpMethod": "POST",
"risk": "write",
"scopes": [
"calendar:calendar.event:create"
],
"parameters": {
"calendar_id": {
"type": "string",
"location": "path",
"required": true
}
}
}
}
}
}
},
{
"name": "im",
"version": "v1",
"title": "IM API",
"servicePath": "/open-apis/im/v1",
"resources": {
"chat.members": {
"methods": {
"create": {
"path": "chats/{chat_id}/members",
"httpMethod": "POST",
"risk": "write",
"scopes": [
"im:chat",
"im:chat.members:write_only"
],
"parameters": {
"chat_id": {
"type": "string",
"location": "path",
"required": true
},
"member_id_type": {
"type": "string",
"location": "query",
"required": false
}
}
}
}
}
}
},
{
"name": "task",
"version": "v2",
"title": "Task API",
"servicePath": "/open-apis/task/v2",
"resources": {}
}
]
}

View File

@@ -1,146 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package registrytest seeds the registry with a tracked metadata fixture so
// command-tree tests pass on a clean checkout — no `make fetch_meta`, no
// network, no user cache. TestMain funcs of packages that build service
// commands call Seed after redirecting LARKSUITE_CLI_CONFIG_DIR.
package registrytest
import (
_ "embed"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/vfs"
)
// fixtureMetaJSON is a trimmed snapshot of the generated meta_data.json
// holding only the calendar, im and task services that registry-backed tests
// assert against. Its version is pinned to "0.0.1": newer than the empty
// embedded stub ("0.0.0") so it wins on a clean checkout, older than any real
// generated catalog ("1.0.0"+) so a `make fetch_meta` build keeps testing the
// full embedded data.
//
//go:embed fixture_meta.json
var fixtureMetaJSON []byte
// Seed writes fixtureMetaJSON into the registry remote-meta cache under
// LARKSUITE_CLI_CONFIG_DIR and eagerly initializes the registry. testRoot must
// be the temporary root created by the caller's TestMain; Seed rejects a config
// directory outside it before performing any write. The cache
// meta is stamped fresh so Init never sync-fetches or background-refreshes
// over the network. Eager Init pins the catalog for the whole test process before
// any individual test can re-point LARKSUITE_CLI_CONFIG_DIR elsewhere.
//
// The caller's TestMain must set LARKSUITE_CLI_CONFIG_DIR beneath testRoot
// first; Seed refuses unset, mismatched, or escaping paths so it can never
// write into a developer's real ~/.lark-cli.
func Seed(testRoot string) error {
configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR")
if err := validateConfigDir(testRoot, configDir); err != nil {
return err
}
var fixture struct {
Version string `json:"version"`
}
if err := json.Unmarshal(fixtureMetaJSON, &fixture); err != nil {
return err
}
cacheDir := filepath.Join(configDir, "cache")
if err := vfs.MkdirAll(cacheDir, 0o700); err != nil {
return err
}
if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.json"), fixtureMetaJSON, 0o644); err != nil {
return err
}
cacheMeta, err := json.Marshal(registry.CacheMeta{
LastCheckAt: time.Now().Unix(),
Version: fixture.Version,
Brand: string(core.BrandFeishu),
})
if err != nil {
return err
}
if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.meta.json"), cacheMeta, 0o644); err != nil {
return err
}
// Neutralize ambient knobs that would defeat the seeding: an inherited
// LARKSUITE_CLI_REMOTE_META=off would stop Init from reading the seeded
// cache at all, and LARKSUITE_CLI_META_TTL=0 would expire the freshness
// stamp and start a background network refresh from inside unit tests.
if err := os.Unsetenv("LARKSUITE_CLI_REMOTE_META"); err != nil {
return err
}
if err := os.Unsetenv("LARKSUITE_CLI_META_TTL"); err != nil {
return err
}
registry.Init()
// Init is a sync.Once, so the seed is pinned for the whole test process.
// Turning remote metadata off afterwards cannot un-seed anything; it is a
// guard for any future post-Init code path that might consult the remote
// cache again after a test re-points LARKSUITE_CLI_CONFIG_DIR elsewhere.
if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil {
return err
}
// Self-check: both the fixture and any real generated catalog contain the
// im service. If it is missing, the cache seeding silently stopped working
// (e.g. the registry cache file names or freshness semantics changed) and
// every registry-backed test would fail confusingly — fail loudly here
// instead, pointing at this package.
merged, ok := registry.ServiceTyped("im")
if !ok {
return errors.New("registrytest.Seed: registry has no im service after seeding — " +
"the remote-cache format in internal/registry/remote.go may have changed; update registrytest to match")
}
// Self-check: on a fetch_meta build the real embedded catalog must win over
// the 0.0.1 fixture. If the merged im service diverges from the embedded
// one, the version arbitration flipped (e.g. the generated catalog version
// stopped parsing as semver) and unit tests would silently run against the
// stale trimmed fixture instead of the fresh catalog.
for _, service := range registry.EmbeddedServicesTyped() {
if service.Name != "im" {
continue
}
if service.Version != merged.Version {
return errors.New("registrytest.Seed: the fixture shadowed the real embedded catalog — " +
"check the meta_data.json version against the fixture's \"0.0.1\" arbitration in this package")
}
break
}
return nil
}
// validateConfigDir guards the one real hazard: a TestMain wiring mistake
// pointing LARKSUITE_CLI_CONFIG_DIR at a developer's real directory. Both
// paths come from the caller's own MkdirTemp, so a plain containment check
// is enough.
func validateConfigDir(testRoot, configDir string) error {
if testRoot == "" || configDir == "" {
return errors.New("registrytest.Seed: test root and config dir must be set")
}
if !filepath.IsAbs(testRoot) || !filepath.IsAbs(configDir) {
return errors.New("registrytest.Seed: test root and config dir must be absolute")
}
rel, err := filepath.Rel(filepath.Clean(testRoot), filepath.Clean(configDir))
if err != nil {
return err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return errors.New("registrytest.Seed: config dir must stay inside the test root")
}
return nil
}

View File

@@ -1,229 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registrytest
import (
"net/http"
"os"
"path/filepath"
"slices"
"sort"
"testing"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/registry"
)
func TestValidateConfigDir(t *testing.T) {
root := t.TempDir()
tests := []struct {
name string
testRoot string
configDir string
wantErr bool
}{
{name: "equal", testRoot: root, configDir: root},
{name: "child", testRoot: root, configDir: filepath.Join(root, "config")},
{
name: "sibling",
testRoot: root,
configDir: filepath.Join(filepath.Dir(root), "outside"),
wantErr: true,
},
{name: "empty root", configDir: root, wantErr: true},
{name: "empty config", testRoot: root, wantErr: true},
{name: "relative root", testRoot: "relative", configDir: root, wantErr: true},
{name: "relative config", testRoot: root, configDir: "relative", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateConfigDir(tt.testRoot, tt.configDir)
if (err != nil) != tt.wantErr {
t.Fatalf("validateConfigDir() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestFixtureContract(t *testing.T) {
if len(fixtureMetaJSON) > 20<<10 {
t.Fatalf("fixture size = %d, want <= %d", len(fixtureMetaJSON), 20<<10)
}
reg, err := meta.Parse(fixtureMetaJSON)
if err != nil {
t.Fatalf("meta.Parse() error = %v", err)
}
if reg.Version != "0.0.1" {
t.Fatalf("fixture version = %q, want 0.0.1", reg.Version)
}
gotNames := make([]string, 0, len(reg.Services))
for _, service := range reg.Services {
gotNames = append(gotNames, service.Name)
}
sort.Strings(gotNames)
if !slices.Equal(gotNames, []string{"calendar", "im", "task"}) {
t.Fatalf("fixture services = %v, want [calendar im task]", gotNames)
}
calendarCreate := fixtureMethod(t, reg, "calendar", "events", "create")
assertMethodContract(t, calendarCreate, "calendars/{calendar_id}/events", http.MethodPost)
calendarID, ok := calendarCreate.Parameters["calendar_id"]
if !ok || calendarID.Location != "path" || !calendarID.Required {
t.Fatalf("calendar_id = %+v, want required path parameter", calendarID)
}
if !slices.Contains(calendarCreate.Scopes, "calendar:calendar.event:create") {
t.Fatalf("calendar create scopes = %v, want calendar:calendar.event:create", calendarCreate.Scopes)
}
imCreate := fixtureMethod(t, reg, "im", "chat.members", "create")
assertMethodContract(t, imCreate, "chats/{chat_id}/members", http.MethodPost)
chatID, ok := imCreate.Parameters["chat_id"]
if !ok || chatID.Location != "path" || !chatID.Required {
t.Fatalf("chat_id = %+v, want required path parameter", chatID)
}
memberIDType, ok := imCreate.Parameters["member_id_type"]
if !ok || memberIDType.Location != "query" || memberIDType.Required {
t.Fatalf("member_id_type = %+v, want optional query parameter", memberIDType)
}
if imCreate.Risk != "write" {
t.Fatalf("im create risk = %q, want write", imCreate.Risk)
}
for _, scope := range []string{"im:chat", "im:chat.members:write_only"} {
if !slices.Contains(imCreate.Scopes, scope) {
t.Fatalf("im create scopes = %v, want %s", imCreate.Scopes, scope)
}
}
}
func fixtureMethod(t *testing.T, reg meta.Registry, serviceName, resourceName, methodName string) meta.Method {
t.Helper()
for _, service := range reg.Services {
if service.Name != serviceName {
continue
}
resource, ok := service.Resource(resourceName)
if !ok {
t.Fatalf("fixture service %s has no resource %s", serviceName, resourceName)
}
method, ok := resource.Method(methodName)
if !ok {
t.Fatalf("fixture resource %s.%s has no method %s", serviceName, resourceName, methodName)
}
return method
}
t.Fatalf("fixture has no service %s", serviceName)
return meta.Method{}
}
func assertMethodContract(t *testing.T, method meta.Method, path, httpMethod string) {
t.Helper()
if method.Path != path || method.HTTPMethod != httpMethod {
t.Fatalf("method = %s %s, want %s %s", method.HTTPMethod, method.Path, httpMethod, path)
}
}
// TestSeedRejectsUnsafeConfigDir pins Seed's guard: it must return before
// writing anything when LARKSUITE_CLI_CONFIG_DIR is unset or escapes the
// caller's test root, so a TestMain wiring mistake can never touch a
// developer's real ~/.lark-cli.
func TestSeedRejectsUnsafeConfigDir(t *testing.T) {
root := t.TempDir()
t.Run("unset config dir", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "")
if err := Seed(root); err == nil {
t.Fatal("Seed() error = nil, want unset config dir rejection")
}
})
t.Run("config dir outside test root", func(t *testing.T) {
outside := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", outside)
if err := Seed(root); err == nil {
t.Fatal("Seed() error = nil, want containment rejection")
}
if _, err := os.Stat(filepath.Join(outside, "cache")); err == nil {
t.Fatal("Seed wrote into the rejected config dir")
}
})
}
// TestSeedWritesFixtureAndInitializesRegistry covers the seeding happy path:
// cache files land under the config dir, the registry initializes from them,
// and both self-checks pass.
func TestSeedWritesFixtureAndInitializesRegistry(t *testing.T) {
root := t.TempDir()
configDir := filepath.Join(root, "config")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
if err := Seed(root); err != nil {
t.Fatalf("Seed() error = %v, want nil", err)
}
for _, name := range []string{"remote_meta.json", "remote_meta.meta.json"} {
if _, err := os.Stat(filepath.Join(configDir, "cache", name)); err != nil {
t.Errorf("cache file %s: %v", name, err)
}
}
if got := os.Getenv("LARKSUITE_CLI_REMOTE_META"); got != "off" {
t.Errorf("LARKSUITE_CLI_REMOTE_META = %q, want off after seeding", got)
}
for _, service := range []string{"calendar", "im", "task"} {
if _, ok := registry.ServiceTyped(service); !ok {
t.Errorf("registry missing service %s after seeding", service)
}
}
}
// TestSeedPropagatesCacheSetupFailures pins that filesystem failures while
// materializing the cache surface as errors instead of leaving the registry
// silently unseeded. Each obstacle is a same-named file/directory in the
// way, which fails on every platform without permission tricks.
func TestSeedPropagatesCacheSetupFailures(t *testing.T) {
seedWith := func(t *testing.T, prepare func(root, configDir string)) error {
t.Helper()
root := t.TempDir()
configDir := filepath.Join(root, "config")
prepare(root, configDir)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
return Seed(root)
}
t.Run("cache dir creation fails", func(t *testing.T) {
err := seedWith(t, func(root, configDir string) {
// config is a regular file, so MkdirAll(config/cache) fails.
if err := os.WriteFile(configDir, nil, 0o600); err != nil {
t.Fatal(err)
}
})
if err == nil {
t.Fatal("Seed() error = nil, want cache dir creation failure")
}
})
t.Run("fixture write fails", func(t *testing.T) {
err := seedWith(t, func(root, configDir string) {
// remote_meta.json is a directory, so WriteFile fails.
if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.json"), 0o700); err != nil {
t.Fatal(err)
}
})
if err == nil {
t.Fatal("Seed() error = nil, want fixture write failure")
}
})
t.Run("cache meta write fails", func(t *testing.T) {
err := seedWith(t, func(root, configDir string) {
// remote_meta.meta.json is a directory, so WriteFile fails.
if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.meta.json"), 0o700); err != nil {
t.Fatal(err)
}
})
if err == nil {
t.Fatal("Seed() error = nil, want cache meta write failure")
}
})
}

View File

@@ -1,27 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"os"
"path/filepath"
"testing"
)
func TestMain(m *testing.M) {
root, err := os.MkdirTemp("", "lark-cli-registry-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()
// A test that ran Init without a trailing resetInit can leave a background
// refresh goroutine alive; removing the temp root while it writes would
// let it recreate the directory after cleanup. Wait it out first.
waitBackgroundRefresh()
_ = os.RemoveAll(root)
os.Exit(code)
}

View File

@@ -1,142 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deviceinfo collects the platform hardware product model and the
// platform values used by device-related risk-control headers.
package riskcontrol
import (
"runtime"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/net/http/httpguts"
)
// OSType is the server-side risk-control operating-system enum.
type OSType string
// OS type enum values for X-Agent-Os-Type.
const (
OSTypeUnknown = "0"
OSTypeWindows = "1"
OSTypeLinux = "2"
OSTypeMacOS = "3"
)
const (
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
TerminalTypePC = "1"
// Unknown is used when the hardware product model cannot be collected.
Unknown = "Unknown"
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
// Device models are short identifiers; a larger value is treated as
// malformed rather than truncated so the header never misrepresents it.
deviceModelMaxBytes = 256
)
// Snapshot contains the deliberately small risk-control signal set.
// ProductModel is omitted when the platform cannot provide a safe value.
type Snapshot struct {
OSType OSType
ProductModel string
}
// Source supplies one immutable process-level snapshot.
type Source interface {
Snapshot() Snapshot
}
// HostSource lazily reads host signals once, after outbound policy authorizes
// the first request. Failed probes are cached and are not retried per request.
type HostSource struct {
once sync.Once
value Snapshot
readModel func() string
}
// NewHostSource creates the production host signal source.
func NewHostSource() *HostSource {
return &HostSource{readModel: readDeviceModel}
}
// Snapshot returns the cached host signal snapshot.
func (s *HostSource) Snapshot() Snapshot {
if s == nil {
return Snapshot{}
}
s.once.Do(func() {
readModel := s.readModel
if readModel == nil {
readModel = readDeviceModel
}
s.value = Snapshot{
OSType: GetOSType(OSName()),
ProductModel: normalizeDeviceModel(readModel()),
}
})
return s.value
}
// normalizeModel removes non-printable characters and returns a model only
// when the remaining text is safe to use as an HTTP header value. Input that
// cannot produce a valid model is rejected so Get can fall back to Unknown.
func normalizeDeviceModel(model string) string {
if !utf8.ValidString(model) {
return ""
}
model = strings.Map(func(r rune) rune {
switch {
case r == '\r' || r == '\n' || r == '\x00':
return -1
case unicode.IsSpace(r):
return ' '
case unicode.IsPrint(r):
return r
default:
return -1
}
}, model)
model = strings.Join(strings.Fields(model), " ")
if model == "" || len(model) > deviceModelMaxBytes {
return ""
}
if !httpguts.ValidHeaderFieldValue(model) {
return ""
}
return model
}
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
func GetOSType(osName string) OSType {
switch osName {
case "Windows":
return OSTypeWindows
case "Linux":
return OSTypeLinux
case "MacOS":
return OSTypeMacOS
default:
return OSTypeUnknown
}
}
// OSName returns the platform name used by GetOSType.
func OSName() string {
switch runtime.GOOS {
case "darwin":
return "MacOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return runtime.GOOS
}
}

View File

@@ -1,27 +0,0 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/unix"
// readDeviceModel reads the current product key first and falls back to the
// legacy model key. Trying both keys is more robust than branching on a macOS
// version because virtualized or restricted environments may expose only one.
func readDeviceModel() string {
return readDarwinDeviceModel(unix.Sysctl)
}
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
for _, key := range [...]string{"hw.product", "hw.model"} {
model, err := readSysctl(key)
if err == nil {
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
}
return ""
}

View File

@@ -1,48 +0,0 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
t.Run("product available", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.product" {
return "Mac16,1", nil
}
return "", errors.New("unexpected fallback")
})
if got != "Mac16,1" {
t.Fatalf("model = %q, want %q", got, "Mac16,1")
}
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
t.Run("product unavailable", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.model" {
return "MacBookPro18,3", nil
}
return "", errors.New("not available")
})
if got != "MacBookPro18,3" {
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
}
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
}

View File

@@ -1,17 +0,0 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
// values vary widely and can expose the host or virtualization platform when
// the CLI runs in a container or sandbox.
func readDeviceModel() string {
return readLinuxDeviceModel()
}
func readLinuxDeviceModel() string {
return "linux"
}

View File

@@ -1,20 +0,0 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "testing"
func TestReadDeviceModelReturnsLinux(t *testing.T) {
if got := readDeviceModel(); got != "linux" {
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
}
}
func TestReadLinuxDeviceModel(t *testing.T) {
if got := readLinuxDeviceModel(); got != "linux" {
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
}
}

View File

@@ -1,11 +0,0 @@
//go:build !darwin && !windows && !linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns an empty model on unsupported platforms.
func readDeviceModel() string {
return ""
}

View File

@@ -1,143 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"unicode"
)
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return " MacBookPro18,3\n"
}}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceCachesEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return ""
}}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
var calls atomic.Int32
s := &HostSource{readModel: func() string {
calls.Add(1)
return "ThinkPad X1 Carbon"
}}
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
snapshot := s.Snapshot()
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
}
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("read called %d times, want 1", got)
}
}
func TestNormalizeDeviceModel(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
{name: "rejects empty", model: " \t\r\n"},
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
{name: "normalizes tab", model: "model\tname", want: "model name"},
{name: "removes NUL", model: "model\x00name", want: "modelname"},
{name: "removes control character", model: "model\x1fname", want: "modelname"},
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDeviceModel(tt.model); got != tt.want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
for value := 0; value <= 0x7f; value++ {
if value >= 0x20 && value < 0x7f {
continue
}
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
model := "model" + string(rune(value)) + "name"
want := "modelname"
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
want = "model name"
}
if got := normalizeDeviceModel(model); got != want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
}
})
}
}
func TestGetOSType(t *testing.T) {
tests := []struct {
name string
want OSType
}{
{name: "Windows", want: OSTypeWindows},
{name: "Linux", want: OSTypeLinux},
{name: "MacOS", want: OSTypeMacOS},
{name: "unknown", want: OSTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetOSType(tt.name); got != tt.want {
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
}
})
}
}

View File

@@ -1,44 +0,0 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/windows/registry"
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
var systemInfoRegistryPaths = [...]string{
`HARDWARE\DESCRIPTION\System\BIOS`,
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
`SYSTEM\HardwareConfig\Current`,
}
// readDeviceModel returns the first product name found in the Windows registry.
func readDeviceModel() string {
return readWindowsDeviceModel(readWindowsRegistryModel)
}
func readWindowsRegistryModel(path string) (string, error) {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
if err != nil {
return "", err
}
defer key.Close()
model, _, err := key.GetStringValue("SystemProductName")
return model, err
}
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
for _, path := range systemInfoRegistryPaths {
model, err := readRegistryModel(path)
if err != nil {
continue
}
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
return ""
}

View File

@@ -1,78 +0,0 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadWindowsDeviceModelFallback(t *testing.T) {
readError := errors.New("registry read failed")
tests := []struct {
name string
values map[string]string
errors map[string]error
want string
wantPaths []string
}{
{
name: "first path wins",
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
want: "Surface Laptop",
wantPaths: []string{systemInfoRegistryPaths[0]},
},
{
name: "read failure falls back",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
},
values: map[string]string{
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
},
want: "ThinkPad X1 Carbon",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "empty normalized value falls back",
values: map[string]string{
systemInfoRegistryPaths[0]: " \r\n\x00",
systemInfoRegistryPaths[1]: "Latitude 7450",
},
want: "Latitude 7450",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "all paths fail",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
systemInfoRegistryPaths[1]: readError,
systemInfoRegistryPaths[2]: readError,
},
wantPaths: systemInfoRegistryPaths[:],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var paths []string
got := readWindowsDeviceModel(func(path string) (string, error) {
paths = append(paths, path)
if err := tt.errors[path]; err != nil {
return "", err
}
return tt.values[path], nil
})
if got != tt.want {
t.Fatalf("model = %q, want %q", got, tt.want)
}
if !reflect.DeepEqual(paths, tt.wantPaths) {
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
}
})
}
}

View File

@@ -1,138 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
const (
HeaderProductModel = "X-Agent-Device-Type"
HeaderOSType = "X-Agent-Os-Type"
)
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
// Transport is the feature's final outbound boundary. It removes caller- or
// extension-supplied signal headers first and writes trusted values only after
// authorizing an official SDK origin and authentication state.
type Transport struct {
next http.RoundTripper
source Source
}
// NewTransport creates the final SDK outbound policy boundary. A nil source
// disables collection and injection while preserving restricted-header
// stripping for opt-out and extension-credential requests.
func NewTransport(next http.RoundTripper, source Source) *Transport {
if next == nil {
next = internaltransport.Fallback()
}
return &Transport{
next: next,
source: source,
}
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
if req.Header == nil {
req.Header = make(http.Header)
}
stripRestrictedHeaders(req.Header)
if t.source != nil && t.routeAllowsSignals(req) {
snapshot := t.source.Snapshot()
if isSupportedOSType(snapshot.OSType) {
req.Header.Set(HeaderOSType, string(snapshot.OSType))
}
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
req.Header.Set(HeaderProductModel, model)
}
}
return t.next.RoundTrip(req)
}
func isSupportedOSType(value OSType) bool {
switch value {
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
return true
default:
return false
}
}
func stripRestrictedHeaders(header http.Header) {
for name := range header {
for _, restricted := range restrictedHeaders {
if strings.EqualFold(name, restricted) {
delete(header, name)
break
}
}
}
}
type origin struct {
scheme string
host string
port string
}
var officialFeishuOrigins = [...]origin{
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
}
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
if req == nil || req.URL == nil {
return false
}
return isOfficialFeishuOrigin(originOf(req.URL))
}
func originOf(value *url.URL) origin {
if value == nil {
return origin{}
}
scheme := strings.ToLower(value.Scheme)
port := value.Port()
if port == "" {
switch scheme {
case "https":
port = "443"
case "http":
port = "80"
}
}
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
}
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
endpoint, err := url.Parse(endpointURL)
if err != nil {
return origin{}
}
return originOf(endpoint)
}
func isOfficialFeishuOrigin(candidate origin) bool {
if candidate.scheme != "https" || candidate.port != "443" {
return false
}
for _, official := range officialFeishuOrigins {
if candidate == official {
return true
}
}
return false
}

View File

@@ -1,124 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"strings"
"sync/atomic"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type countingSource struct {
calls atomic.Int32
}
func (s *countingSource) Snapshot() Snapshot {
s.calls.Add(1)
return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
}
type staticSource Snapshot
func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
tests := []struct {
name string
requestURL string
authorization string
wantSignals bool
}{
{name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
{name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
{name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
{name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
{name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := &countingSource{}
var received http.Header
base := 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, test.requestURL, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", test.authorization)
req.Header.Set(HeaderOSType, "caller-value")
req.Header.Set(HeaderProductModel, "caller-value")
req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
resp, err := NewTransport(base, source).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
gotSignals := received.Get(HeaderOSType) != ""
if gotSignals != test.wantSignals {
t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
}
wantCalls := int32(0)
if test.wantSignals {
wantCalls = 1
}
if got := source.calls.Load(); got != wantCalls {
t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
}
if got := req.Header.Get(HeaderOSType); got != "caller-value" {
t.Fatalf("caller request OS header = %q, want unchanged", got)
}
if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
t.Fatalf("caller request product-model header = %q, want unchanged", got)
}
if !test.wantSignals {
for name := range received {
if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
t.Fatalf("restricted header leaked as %q", name)
}
}
}
})
}
}
func TestTransportValidatesSourceSnapshot(t *testing.T) {
var received http.Header
base := 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 := NewTransport(base, staticSource{
OSType: OSType("unsupported"),
ProductModel: "unsafe\nvalue",
}).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
t.Fatalf("no signals collected: %v", received)
}
}

View File

@@ -1,55 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package gitcmd provides Git process helpers for tests that use temporary
// repositories.
package gitcmd
import (
"os"
"os/exec"
"strconv"
"testing"
)
const (
maintenanceAutoDetach = "maintenance.autoDetach"
gcAutoDetach = "gc.autoDetach"
)
// Command creates a Git command whose automatic maintenance stays in the
// command lifecycle, so temporary repository cleanup cannot race a detached
// maintenance process.
func Command(dir string, args ...string) *exec.Cmd {
commandArgs := make([]string, 0, len(args)+4)
commandArgs = append(commandArgs,
"-c", maintenanceAutoDetach+"=false",
"-c", gcAutoDetach+"=false",
)
commandArgs = append(commandArgs, args...)
cmd := exec.Command("git", commandArgs...)
cmd.Dir = dir
return cmd
}
// SetSynchronousMaintenanceEnv applies the same lifecycle contract to every
// Git process started by the current test, including processes created through
// production command runners. Tests using it must not run in parallel.
func SetSynchronousMaintenanceEnv(t *testing.T) {
t.Helper()
count := 0
if value, ok := os.LookupEnv("GIT_CONFIG_COUNT"); ok {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 0 {
t.Fatalf("invalid GIT_CONFIG_COUNT %q", value)
}
count = parsed
}
for _, key := range []string{maintenanceAutoDetach, gcAutoDetach} {
index := strconv.Itoa(count)
t.Setenv("GIT_CONFIG_KEY_"+index, key)
t.Setenv("GIT_CONFIG_VALUE_"+index, "false")
count++
}
t.Setenv("GIT_CONFIG_COUNT", strconv.Itoa(count))
}

View File

@@ -1,47 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package gitcmd
import (
"os/exec"
"strings"
"testing"
)
func TestCommandDisablesDetachedMaintenance(t *testing.T) {
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
cmd := Command(t.TempDir(), "config", "--get", "--type=bool", key)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git config %s: %v\n%s", key, err, out)
}
if got := strings.TrimSpace(string(out)); got != "false" {
t.Fatalf("%s = %q, want false", key, got)
}
}
}
func TestSetSynchronousMaintenanceEnv(t *testing.T) {
t.Setenv("GIT_CONFIG_COUNT", "1")
t.Setenv("GIT_CONFIG_KEY_0", "user.name")
t.Setenv("GIT_CONFIG_VALUE_0", "Existing Test User")
SetSynchronousMaintenanceEnv(t)
for key, want := range map[string]string{
"user.name": "Existing Test User",
maintenanceAutoDetach: "false",
gcAutoDetach: "false",
} {
cmd := exec.Command("git", "config", "--get", "--type=bool", key)
if key == "user.name" {
cmd = exec.Command("git", "config", "--get", key)
}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git config %s: %v\n%s", key, err, out)
}
if got := strings.TrimSpace(string(out)); got != want {
t.Fatalf("%s = %q, want %q", key, got, want)
}
}
}

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