mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
109 Commits
feat/chart
...
feat/sessi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4769b5c3e8 | ||
|
|
c86c348fa9 | ||
|
|
8587203afb | ||
|
|
48b0ade294 | ||
|
|
0fef0667fd | ||
|
|
754f471f75 | ||
|
|
8bcb04c830 | ||
|
|
54428113f5 | ||
|
|
01fdf2f4d6 | ||
|
|
3a9cfdda93 | ||
|
|
51f3f07f44 | ||
|
|
16ed51252e | ||
|
|
6b80706300 | ||
|
|
76bd47a1c8 | ||
|
|
f6732c9afa | ||
|
|
e28f91794a | ||
|
|
dd42477a82 | ||
|
|
fd9940ce5e | ||
|
|
f01f7abbef | ||
|
|
76ee4cd05e | ||
|
|
cf2af70b98 | ||
|
|
7b32b22069 | ||
|
|
0c8bf43023 | ||
|
|
ce00fdcce4 | ||
|
|
7cc881c7a3 | ||
|
|
a340417767 | ||
|
|
ccc6dd3e47 | ||
|
|
beeeb71cd6 | ||
|
|
7c04a1a60e | ||
|
|
eabc8558d5 | ||
|
|
9cad3ca0e1 | ||
|
|
4306f41060 | ||
|
|
d8f877f60f | ||
|
|
6aa8dec967 | ||
|
|
70b611ab2c | ||
|
|
4eb068fc20 | ||
|
|
417777ff58 | ||
|
|
5a0f022a97 | ||
|
|
fe3d94935a | ||
|
|
71521b967d | ||
|
|
1d9f48b62f | ||
|
|
c4f50226a4 | ||
|
|
9b1f3fa01c | ||
|
|
866636563e | ||
|
|
c24a38289a | ||
|
|
e6466b9b14 | ||
|
|
deb16ce1fb | ||
|
|
b6c19f5d1d | ||
|
|
b1d4489657 | ||
|
|
18451622ea | ||
|
|
38e8806d91 | ||
|
|
a7865cd0a7 | ||
|
|
f77b7eea68 | ||
|
|
dd7f741b62 | ||
|
|
e7d5ecdd01 | ||
|
|
4807283368 | ||
|
|
d2bb36591f | ||
|
|
5a54bc07db | ||
|
|
a528b3cb69 | ||
|
|
f0176af330 | ||
|
|
715aa8d960 | ||
|
|
ebc0c53ab5 | ||
|
|
1e682bd97c | ||
|
|
70424c486c | ||
|
|
b8f56dbc0b | ||
|
|
c74d9b63fb | ||
|
|
67015eef8e | ||
|
|
af8507ea8e | ||
|
|
02c2ebcf7c | ||
|
|
abf6f99d7e | ||
|
|
8ba910eb9f | ||
|
|
78bf126bb0 | ||
|
|
4eefe32c1a | ||
|
|
8f6f8eb0fc | ||
|
|
80323bb464 | ||
|
|
0a33bd7c57 | ||
|
|
aafaed06a7 | ||
|
|
54ddcf490b | ||
|
|
bb246b591f | ||
|
|
fc2761d16b | ||
|
|
409a3172da | ||
|
|
483aadee3b | ||
|
|
e43f497650 | ||
|
|
990d633c07 | ||
|
|
d4168ab84f | ||
|
|
12ca42c953 | ||
|
|
d382ee9053 | ||
|
|
daaacb4977 | ||
|
|
680501c1df | ||
|
|
6675e3c247 | ||
|
|
7b48709438 | ||
|
|
c876841106 | ||
|
|
4c1a92caa6 | ||
|
|
577ff035c3 | ||
|
|
4b4ca4283a | ||
|
|
ad4a6d68c7 | ||
|
|
d8fb368ce4 | ||
|
|
40840915c7 | ||
|
|
fb57e17905 | ||
|
|
4cdfa2fcda | ||
|
|
3c2cc273f7 | ||
|
|
b52677269e | ||
|
|
78390f8ea1 | ||
|
|
d6cebd6723 | ||
|
|
79adf89beb | ||
|
|
9dd355a52d | ||
|
|
7b989948c4 | ||
|
|
6ff10229fd | ||
|
|
21cff2e2dd |
103
.github/workflows/release.yml
vendored
103
.github/workflows/release.yml
vendored
@@ -9,7 +9,40 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
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
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -26,35 +59,79 @@ 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: ${{ secrets.GITHUB_TOKEN }}
|
||||
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
|
||||
|
||||
publish-npm:
|
||||
needs: goreleaser
|
||||
needs: build-release
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- 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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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; }
|
||||
(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"
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
## 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 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 live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Notification Opt-Outs
|
||||
|
||||
119
CHANGELOG.md
119
CHANGELOG.md
@@ -2,6 +2,121 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
@@ -1552,6 +1667,10 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[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
|
||||
|
||||
9
Makefile
9
Makefile
@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
.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
|
||||
|
||||
all: test
|
||||
|
||||
@@ -51,13 +51,18 @@ 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/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/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
|
||||
|
||||
# ./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:
|
||||
|
||||
23
README.md
23
README.md
@@ -285,6 +285,29 @@ 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
|
||||
|
||||
23
README.zh.md
23
README.zh.md
@@ -286,6 +286,29 @@ 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
|
||||
|
||||
@@ -344,20 +344,18 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
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.
|
||||
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
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
|
||||
396
cmd/api/api_paginate_test.go
Normal file
396
cmd/api/api_paginate_test.go
Normal file
@@ -0,0 +1,396 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -352,6 +352,9 @@ 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,
|
||||
})
|
||||
@@ -371,8 +374,33 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "binary response detected") {
|
||||
t.Error("expected binary response hint in stderr")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "saved_path") {
|
||||
t.Error("expected saved_path in output")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -386,7 +386,7 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
|
||||
|
||||
appInfoStub := &httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
@@ -442,7 +442,7 @@ func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T)
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
@@ -485,6 +485,18 @@ type authScopesTokenResolver struct {
|
||||
requests []credential.TokenSpec
|
||||
}
|
||||
|
||||
type authTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r authTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
|
||||
}
|
||||
|
||||
func newAuthTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, authTestAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
r.requests = append(r.requests, req)
|
||||
switch req.Type {
|
||||
|
||||
@@ -27,6 +27,9 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "View current auth status",
|
||||
Long: `Show OAuth user login, token validity, and granted scopes.
|
||||
For token-validity checks, run lark-cli auth status --json --verify.
|
||||
This is not profile/app selection diagnostics; use lark-cli whoami for the effective app/profile identity used by an invocation.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
|
||||
@@ -4,15 +4,35 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAuthStatusHelpDistinguishesFromWhoami(t *testing.T) {
|
||||
cmd := NewCmdAuthStatus(nil, nil)
|
||||
for _, want := range []string{
|
||||
"OAuth user login",
|
||||
"auth status --json --verify",
|
||||
"not profile/app selection diagnostics",
|
||||
"lark-cli whoami",
|
||||
} {
|
||||
if !strings.Contains(cmd.Long, want) {
|
||||
t.Errorf("auth status --help Long missing %q; got:\n%s", want, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
@@ -79,6 +99,51 @@ func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type fixedStatusAccountResolver struct {
|
||||
account *credential.Account
|
||||
}
|
||||
|
||||
func (r *fixedStatusAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return r.account, nil
|
||||
}
|
||||
|
||||
func TestAuthStatus_AllowsMatchingAppIDOnlySelectedProfile(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliAppID, "cli_a")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a",
|
||||
AppId: "cli_a",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
config := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, config)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&envprovider.Provider{}},
|
||||
&fixedStatusAccountResolver{account: credential.AccountFromCliConfig(config)},
|
||||
nil,
|
||||
nil,
|
||||
).WithProfileFromFlag("tenant_a")
|
||||
|
||||
cmd := NewCmdAuth(f)
|
||||
cmd.SetArgs([]string{"status", "--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status should use the selected built-in profile: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "credentials are provided externally") {
|
||||
t.Fatalf("matching APP_ID-only env was misclassified as external:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
type statusOutput struct {
|
||||
Identity string `json:"identity"`
|
||||
Verified *bool `json:"verified"`
|
||||
|
||||
46
cmd/auth/testmain_test.go
Normal file
46
cmd/auth/testmain_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -6,8 +6,10 @@ package cmd
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -26,5 +28,13 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
|
||||
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
||||
return cmdutil.InvocationContext{}, err
|
||||
}
|
||||
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
|
||||
|
||||
profileFromFlag := fs.Changed("profile")
|
||||
if !profileFromFlag {
|
||||
globals.Profile = os.Getenv(envvars.CliProfile)
|
||||
}
|
||||
return cmdutil.InvocationContext{
|
||||
Profile: globals.Profile,
|
||||
ProfileFromFlag: profileFromFlag,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||
@@ -70,3 +74,58 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
|
||||
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapProfileEnvFallback(t *testing.T) {
|
||||
t.Run("flag wins over env", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "tenant_flag", "whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "tenant_flag" {
|
||||
t.Errorf("got %q, want tenant_flag", inv.Profile)
|
||||
}
|
||||
if !inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = false, want true")
|
||||
}
|
||||
})
|
||||
t.Run("explicit empty flag clears env selection", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile=", "whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Errorf("got %q, want empty", inv.Profile)
|
||||
}
|
||||
if !inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = false, want true")
|
||||
}
|
||||
})
|
||||
t.Run("env used when flag absent", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||
inv, err := BootstrapInvocationContext([]string{"whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "tenant_env" {
|
||||
t.Errorf("got %q, want tenant_env", inv.Profile)
|
||||
}
|
||||
if inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = true, want false")
|
||||
}
|
||||
})
|
||||
t.Run("empty when neither set", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "")
|
||||
inv, err := BootstrapInvocationContext([]string{"whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Errorf("got %q, want empty", inv.Profile)
|
||||
}
|
||||
if inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = true, want false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ 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))
|
||||
|
||||
@@ -84,6 +84,16 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowHelpClarifiesSavedConfig(t *testing.T) {
|
||||
cmd := NewCmdConfigShow(nil, nil)
|
||||
if !strings.Contains(cmd.Short, "saved config") {
|
||||
t.Errorf("config show short = %q, want saved config", cmd.Short)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
|
||||
t.Errorf("config show help missing whoami route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -106,6 +116,77 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// config show promises "saved config, not current usage" (help + skill
|
||||
// routing): the session profile (--profile / LARKSUITE_CLI_PROFILE) must not
|
||||
// change what it shows.
|
||||
func TestConfigShowRun_IgnoresSessionProfile(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu},
|
||||
{Name: "tenant_b", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret-b"), Brand: core.BrandFeishu},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Invocation.Profile = "tenant_b" // session selection must not leak in
|
||||
|
||||
if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("configShowRun: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"cli_a"`) || !strings.Contains(out, `"tenant_a"`) {
|
||||
t.Fatalf("output = %s, want the saved default tenant_a/cli_a", out)
|
||||
}
|
||||
if strings.Contains(out, `"cli_b"`) {
|
||||
t.Fatalf("output = %s, session profile tenant_b must not change saved-config view", out)
|
||||
}
|
||||
}
|
||||
|
||||
// engagedEnvStub simulates a fully engaged external credential provider.
|
||||
type engagedEnvStub struct{}
|
||||
|
||||
func (engagedEnvStub) Name() string { return "env" }
|
||||
func (engagedEnvStub) Priority() int { return 10 }
|
||||
func (engagedEnvStub) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{AppID: "cli_env", AppSecret: "your-password"}, nil // managed takeover
|
||||
}
|
||||
func (engagedEnvStub) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// config show inspects the SAVED config only, so the parent command's
|
||||
// external-credential gate must not apply: even with a fully engaged direct
|
||||
// env credential, `config show` still answers from the saved config.
|
||||
func TestConfigShow_BypassesExternalCredentialGate(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Credential = credential.NewCredentialProvider([]extcred.Provider{engagedEnvStub{}}, nil, nil, nil)
|
||||
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SetArgs([]string{"show"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("config show must bypass the external-credential gate: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"cli_a"`) {
|
||||
t.Fatalf("output = %s, want the saved config shown", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
@@ -481,7 +562,8 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
}{
|
||||
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
|
||||
{"remove", []string{"remove"}},
|
||||
{"show", []string{"show"}},
|
||||
// "show" is deliberately absent: it inspects the SAVED config only
|
||||
// and bypasses this gate (TestConfigShow_BypassesExternalCredentialGate).
|
||||
{"default-as", []string{"default-as", "user"}},
|
||||
{"strict-mode", []string{"strict-mode", "off"}},
|
||||
}
|
||||
|
||||
80
cmd/config/risk_control.go
Normal file
80
cmd/config/risk_control.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// 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"
|
||||
}
|
||||
130
cmd/config/risk_control_test.go
Normal file
130
cmd/config/risk_control_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,16 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show current configuration",
|
||||
Short: "Show saved config",
|
||||
Long: "Shows saved config. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
|
||||
// Override parent's RequireBuiltinCredentialProvider check: this
|
||||
// command reads the SAVED config only (its own help promises "saved
|
||||
// config, not current usage"), so the currently effective credential
|
||||
// source — external or otherwise — must not gate it.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -53,7 +62,10 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
// Saved config only: the session profile (--profile / LARKSUITE_CLI_PROFILE)
|
||||
// must not change what this command shows — the help and skill routing
|
||||
// promise "saved config, not current usage" (use whoami for that).
|
||||
app := config.CurrentAppConfig("")
|
||||
if app == nil {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
||||
}
|
||||
|
||||
@@ -110,8 +110,20 @@ func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSp
|
||||
return nil, errors.New("backend unavailable")
|
||||
}
|
||||
|
||||
type eventTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r eventTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID}, nil
|
||||
}
|
||||
|
||||
func newEventTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, eventTestAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
return &cmdutil.Factory{Credential: newEventTestCredentialProvider("cli_x", r)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
|
||||
@@ -44,7 +44,7 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Credential: newEventTestCredentialProvider("test-app", &staticTokenResolver{}),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
|
||||
@@ -17,11 +17,14 @@ import (
|
||||
)
|
||||
|
||||
// profileListItem is the JSON output for a single profile entry.
|
||||
// `default` (formerly `active`, renamed in this feature as a declared
|
||||
// breaking change) marks the saved default profile — never the identity
|
||||
// effective for the current invocation; that is whoami's job.
|
||||
type profileListItem struct {
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
Active bool `json:"active"`
|
||||
Default bool `json:"default"`
|
||||
User string `json:"user,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
}
|
||||
@@ -30,7 +33,8 @@ type profileListItem struct {
|
||||
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all profiles",
|
||||
Short: "List saved profiles",
|
||||
Long: "Lists saved profiles. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileListRun(f)
|
||||
},
|
||||
@@ -53,7 +57,7 @@ func profileListRun(f *cmdutil.Factory) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
|
||||
// Intentionally uses "" to show the saved default profile, not the ephemeral --profile override.
|
||||
currentApp := multi.CurrentAppConfig("")
|
||||
currentName := ""
|
||||
if currentApp != nil {
|
||||
@@ -66,10 +70,10 @@ func profileListRun(f *cmdutil.Factory) error {
|
||||
name := app.ProfileName()
|
||||
|
||||
item := profileListItem{
|
||||
Name: name,
|
||||
AppID: app.AppId,
|
||||
Brand: app.Brand,
|
||||
Active: name == currentName,
|
||||
Name: name,
|
||||
AppID: app.AppId,
|
||||
Brand: app.Brand,
|
||||
Default: name == currentName,
|
||||
}
|
||||
|
||||
if len(app.Users) > 0 {
|
||||
|
||||
@@ -14,6 +14,17 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "Manage configuration profiles",
|
||||
Long: `Profiles are named app identities managed by lark-cli.
|
||||
|
||||
Identity diagnostics and profile selection:
|
||||
lark-cli whoami --json Show the app/profile lark-cli is using now.
|
||||
lark-cli auth status --json --verify Verify OAuth login and token state.
|
||||
--profile <name> Use a profile for this command only.
|
||||
LARKSUITE_CLI_PROFILE Use a profile for the current shell / agent session.
|
||||
config show / profile list Inspect saved config, not current usage.
|
||||
unset LARKSUITE_CLI_PROFILE Clear the session profile and fall back to direct app env or configured default.
|
||||
|
||||
A selected profile takes precedence over matching direct env credentials and tokens.`,
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
|
||||
@@ -306,14 +306,24 @@ func TestProfileListRun_OutputsProfiles(t *testing.T) {
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
||||
}
|
||||
raw := stdout.String()
|
||||
// `active` is renamed to `default` as a declared breaking change: keeping
|
||||
// a permanently mirrored alias would keep misleading agents into reading
|
||||
// it as the currently effective identity (whoami's job).
|
||||
if strings.Contains(raw, `"active"`) {
|
||||
t.Fatalf("profile list output contains renamed active field: %s", raw)
|
||||
}
|
||||
if !strings.Contains(raw, `"default"`) {
|
||||
t.Fatalf("profile list output missing default field: %s", raw)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "default" || !got[0].Active {
|
||||
t.Fatalf("got[0] = %#v, want active default profile", got[0])
|
||||
if got[0].Name != "default" || !got[0].Default {
|
||||
t.Fatalf("got[0] = %#v, want configured default profile", got[0])
|
||||
}
|
||||
if got[1].Name != "target" || got[1].Active {
|
||||
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
|
||||
if got[1].Name != "target" || got[1].Default {
|
||||
t.Fatalf("got[1] = %#v, want non-default target profile", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,6 +637,39 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestProfileHelpHasSelectionSection asserts `profile --help` documents the
|
||||
// per-invocation flag and session-scoped env var for selecting a profile, so
|
||||
// users and AI agents can find LARKSUITE_CLI_PROFILE without reading source.
|
||||
func TestProfileHelpHasSelectionSection(t *testing.T) {
|
||||
cmd := NewCmdProfile(nil)
|
||||
if !strings.Contains(cmd.Long, "Identity diagnostics and profile selection:") {
|
||||
t.Errorf("profile --help missing identity diagnostics and profile selection section")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "LARKSUITE_CLI_PROFILE") {
|
||||
t.Errorf("profile --help missing LARKSUITE_CLI_PROFILE")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
|
||||
t.Errorf("profile --help missing whoami identity route")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "config show / profile list") {
|
||||
t.Errorf("profile --help missing saved-config boundary")
|
||||
}
|
||||
const precedence = "A selected profile takes precedence over matching direct env credentials and tokens."
|
||||
if !strings.Contains(cmd.Long, precedence) {
|
||||
t.Errorf("profile --help missing precedence statement %q", precedence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListHelpClarifiesSavedProfiles(t *testing.T) {
|
||||
cmd := NewCmdProfileList(nil)
|
||||
if !strings.Contains(cmd.Short, "saved profiles") {
|
||||
t.Errorf("profile list short = %q, want saved profiles", cmd.Short)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
|
||||
t.Errorf("profile list help missing whoami route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
|
||||
dir := setupProfileConfigDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
|
||||
|
||||
@@ -371,10 +371,11 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -707,20 +707,18 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
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.
|
||||
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
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
400
cmd/service/service_paginate_test.go
Normal file
400
cmd/service/service_paginate_test.go
Normal file
@@ -0,0 +1,400 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
39
cmd/service/testmain_test.go
Normal file
39
cmd/service/testmain_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -12,11 +13,34 @@ 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)
|
||||
@@ -54,7 +78,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 os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
|
||||
if isStartupBrandHelper() {
|
||||
// Helper: replicate Execute()'s build wiring with a lark config.
|
||||
buildInternal(
|
||||
context.Background(), cmdutil.InvocationContext{},
|
||||
@@ -71,9 +95,11 @@ 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
|
||||
)
|
||||
@@ -85,3 +111,33 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
46
cmd/testmain_test.go
Normal file
46
cmd/testmain_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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)
|
||||
}
|
||||
23
cmd/update/testmain_test.go
Normal file
23
cmd/update/testmain_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -24,6 +24,8 @@ 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()
|
||||
@@ -31,13 +33,17 @@ 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.
|
||||
// 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.
|
||||
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 })
|
||||
@@ -104,6 +110,18 @@ 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)
|
||||
@@ -228,6 +246,9 @@ 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)
|
||||
@@ -256,6 +277,9 @@ 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)
|
||||
@@ -281,6 +305,7 @@ 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"})
|
||||
@@ -312,6 +337,7 @@ 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{})
|
||||
@@ -1161,6 +1187,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1177,7 +1204,10 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
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)
|
||||
@@ -1197,6 +1227,7 @@ 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")
|
||||
@@ -1513,28 +1544,133 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.Skipf("npx not found in PATH: %v", err)
|
||||
// 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"),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
}
|
||||
|
||||
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()
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Fatalf("live skills tests opted in but 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)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
t.Fatalf("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)
|
||||
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)
|
||||
}
|
||||
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
|
||||
@@ -1630,26 +1766,17 @@ 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; it is
|
||||
// skipped when npx or the skills registry is unavailable.
|
||||
// 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.
|
||||
func TestUpdateCommand_SkillsSyncColdStart(t *testing.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)
|
||||
}
|
||||
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 2: Use an isolated config dir with no pre-existing skills-state.json.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -33,6 +34,15 @@ type whoamiResult struct {
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
|
||||
// CredentialSource, Explicit, and DirectCredentialEnv surface the cached
|
||||
// credential.IdentitySelection computed during resolution (not re-inferred
|
||||
// here). On the non-env extension-provider path CredentialSource is
|
||||
// "extension:<provider>" (e.g. "extension:sidecar"); an empty value only
|
||||
// means the selection was never resolved.
|
||||
CredentialSource string `json:"credentialSource"`
|
||||
Explicit bool `json:"explicit"`
|
||||
DirectCredentialEnv credential.DirectCredentialEnv `json:"directCredentialEnv"`
|
||||
}
|
||||
|
||||
// delegatedUser is the user a user-identity acts on behalf of.
|
||||
@@ -58,6 +68,10 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current effective identity, app, profile, and token status (JSON)",
|
||||
Long: `Show the effective app identity used by this invocation. This is not OAuth login status;
|
||||
use ` + "`lark-cli auth status --json`" + ` for OAuth user/token state.
|
||||
The JSON output includes credentialSource, appId, brand, and whether direct app credential
|
||||
env is present and matches the selected profile.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return whoamiRun(cmd, opts)
|
||||
},
|
||||
@@ -97,7 +111,17 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
// Read the cached selection computed during resolution; never re-infer it
|
||||
// here. A resolution failure (e.g. under a non-env extension provider that
|
||||
// doesn't populate a selection) degrades to the zero value rather than
|
||||
// regressing whoami's own error/diagnostic path above.
|
||||
var selection credential.IdentitySelection
|
||||
if f.Credential != nil {
|
||||
if sel, err := f.Credential.Selection(ctx); err == nil {
|
||||
selection = sel
|
||||
}
|
||||
}
|
||||
res := buildResult(cfg, as, source, diag, selection)
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
@@ -122,18 +146,23 @@ func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, stri
|
||||
|
||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||
// ResolveAs only ever returns user or bot, so the default branch handles user.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
|
||||
// selection is the cached credential.IdentitySelection from resolution; it is
|
||||
// read as-is, never recomputed.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result, selection credential.IdentitySelection) *whoamiResult {
|
||||
defaultAs := cfg.DefaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = core.AsAuto
|
||||
}
|
||||
res := &whoamiResult{
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
CredentialSource: string(selection.Source),
|
||||
Explicit: selection.Explicit(),
|
||||
DirectCredentialEnv: selection.DirectCredentialEnv,
|
||||
}
|
||||
// Use the diagnosed hint as-is: it is tailored to the credential source, so
|
||||
// it never says "auth login" when that is blocked under an external provider.
|
||||
|
||||
@@ -15,10 +15,13 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
func TestResolveSource(t *testing.T) {
|
||||
@@ -52,7 +55,7 @@ func TestBuildResult_UserValid(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
@@ -77,7 +80,7 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -100,7 +103,7 @@ func TestBuildResult_BotReady(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag)
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag, credential.IdentitySelection{})
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default_as" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
@@ -121,7 +124,7 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag, credential.IdentitySelection{})
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -318,3 +321,94 @@ func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
|
||||
t.Fatalf("hint should explain external management: %q", got.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// noopWhoamiKeychain is a no-op KeychainAccess; the profile below uses a
|
||||
// plaintext secret, so no keychain lookup is actually required.
|
||||
type noopWhoamiKeychain struct{}
|
||||
|
||||
func (noopWhoamiKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (noopWhoamiKeychain) Set(service, account, value string) error { return nil }
|
||||
func (noopWhoamiKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
// credentialSourceSecret is the profile secret written to config for
|
||||
// TestWhoamiIncludesCredentialSource. It must never leak into whoami's output
|
||||
// (security: never leak a secret).
|
||||
const credentialSourceSecret = "test-secret"
|
||||
|
||||
// profileSelectionFactory builds a Factory whose CredentialProvider resolves
|
||||
// an explicit profile ("tenant_a") supplied via the LARKSUITE_CLI_PROFILE env
|
||||
// fallback (not --profile), so Selection().Source resolves to
|
||||
// env:LARKSUITE_CLI_PROFILE and Explicit() is true, with no direct
|
||||
// app-credential env vars present.
|
||||
func profileSelectionFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a",
|
||||
AppId: "cli_a",
|
||||
AppSecret: core.PlainSecret(credentialSourceSecret),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return noopWhoamiKeychain{} }, "tenant_a")
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{&envprovider.Provider{}}, defaultAcct, nil, nil)
|
||||
cred.WithProfileFromEnv("tenant_a")
|
||||
|
||||
cfg := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: credentialSourceSecret, Brand: core.BrandFeishu}
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
return f, out
|
||||
}
|
||||
|
||||
// TestWhoamiIncludesCredentialSource locks in the diagnostic fields surfaced
|
||||
// from the cached credential.IdentitySelection: credentialSource,
|
||||
// explicit, and directCredentialEnv. whoami must read the cached selection
|
||||
// as-is, not re-infer it.
|
||||
func TestWhoamiIncludesCredentialSource(t *testing.T) {
|
||||
f, out := profileSelectionFactory(t)
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
raw := out.String()
|
||||
if strings.Contains(raw, credentialSourceSecret) {
|
||||
t.Fatalf("whoami output leaked the profile secret: %s", raw)
|
||||
}
|
||||
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, raw)
|
||||
}
|
||||
if got.CredentialSource != string(credential.SourceEnvProfile) {
|
||||
t.Fatalf("credentialSource = %q, want %q", got.CredentialSource, credential.SourceEnvProfile)
|
||||
}
|
||||
if !got.Explicit {
|
||||
t.Fatalf("explicit = false, want true")
|
||||
}
|
||||
if got.DirectCredentialEnv.Present {
|
||||
t.Fatalf("directCredentialEnv.present = true, want false: %#v", got.DirectCredentialEnv)
|
||||
}
|
||||
if !strings.Contains(raw, `"credentialSource": "env:LARKSUITE_CLI_PROFILE"`) {
|
||||
t.Fatalf("raw JSON missing credentialSource literal: %s", raw)
|
||||
}
|
||||
if got.DirectCredentialEnv.Present || len(got.DirectCredentialEnv.Keys) != 0 ||
|
||||
got.DirectCredentialEnv.AppID != "" || got.DirectCredentialEnv.Matched || got.DirectCredentialEnv.ConflictsWithProfile {
|
||||
t.Fatalf("directCredentialEnv = %#v, want only present:false set", got.DirectCredentialEnv)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,17 @@ Typed errors render to **stderr** as one JSON object per process exit:
|
||||
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
|
||||
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
|
||||
|
||||
Credential/identity-selection extension fields (per-Subtype-stable):
|
||||
|
||||
| Field | Carrier | Subtypes | Notes |
|
||||
|-------|---------|----------|-------|
|
||||
| `missing_keys` | `ConfigError` | `app_credential_incomplete` | env var NAMES that must all be set; never values |
|
||||
| `required_any_of` | `ConfigError` | `app_credential_incomplete` | env var NAMES where any one completes the credential; mutually exclusive with `missing_keys` |
|
||||
| `profile` | `ConfigError` | `profile_not_found`, `profile_secret_invalid` | requested profile name |
|
||||
| `app_id` | `ConfigError` | `profile_secret_invalid` | plaintext app id; never a secret |
|
||||
| `credential_source` | `ConfigError` | `profile_not_found`, `no_active_profile` | how the identity was (not) chosen: `flag:--profile` \| `env:LARKSUITE_CLI_PROFILE` \| `config` |
|
||||
| `profile_app_id`, `env_app_id` | `ValidationError` | `profile_app_credential_conflict` | the two conflicting plaintext app ids |
|
||||
|
||||
`SecurityPolicyError` renders through the same typed envelope as every
|
||||
other category. `error.type` is `"policy"`, `error.subtype` is one of
|
||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||
|
||||
@@ -136,6 +136,79 @@ func TestConfigError_MarshalJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigError_ProfileFieldsMarshalJSON(t *testing.T) {
|
||||
ce := NewConfigError(SubtypeAppCredentialIncomplete, "incomplete").
|
||||
WithMissingKeys("LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET").
|
||||
WithRequiredAnyOf("LARKSUITE_CLI_APP_SECRET", "LARKSUITE_CLI_USER_ACCESS_TOKEN").
|
||||
WithProfile("work").
|
||||
WithAppID("cli_abc").
|
||||
WithCredentialSource("flag:--profile")
|
||||
b, err := json.Marshal(ce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"config"`,
|
||||
`"subtype":"app_credential_incomplete"`,
|
||||
`"missing_keys":["LARKSUITE_CLI_APP_ID","LARKSUITE_CLI_APP_SECRET"]`,
|
||||
`"required_any_of":["LARKSUITE_CLI_APP_SECRET","LARKSUITE_CLI_USER_ACCESS_TOKEN"]`,
|
||||
`"profile":"work"`,
|
||||
`"app_id":"cli_abc"`,
|
||||
`"credential_source":"flag:--profile"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// omitempty: unset fields must not appear on the wire.
|
||||
empty := NewConfigError(SubtypeProfileNotFound, "x")
|
||||
b2, err := json.Marshal(empty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2 := string(b2)
|
||||
for _, notWant := range []string{`"missing_keys"`, `"required_any_of"`, `"profile"`, `"app_id"`, `"credential_source"`} {
|
||||
if strings.Contains(s2, notWant) {
|
||||
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationError_ProfileConflictMarshalJSON(t *testing.T) {
|
||||
ve := NewValidationError(SubtypeProfileAppCredentialConflict, "conflict").
|
||||
WithProfileAppConflict("cli_profile", "cli_env")
|
||||
b, err := json.Marshal(ve)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"validation"`,
|
||||
`"subtype":"profile_app_credential_conflict"`,
|
||||
`"profile_app_id":"cli_profile"`,
|
||||
`"env_app_id":"cli_env"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// omitempty: unset conflict fields must not appear on the wire.
|
||||
empty := NewValidationError(SubtypeInvalidArgument, "x")
|
||||
b2, err := json.Marshal(empty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2 := string(b2)
|
||||
for _, notWant := range []string{`"profile_app_id"`, `"env_app_id"`} {
|
||||
if strings.Contains(s2, notWant) {
|
||||
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkError_MarshalJSON(t *testing.T) {
|
||||
ne := &NetworkError{
|
||||
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},
|
||||
|
||||
@@ -12,8 +12,9 @@ const (
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeProfileAppCredentialConflict Subtype = "profile_app_credential_conflict" // profile and direct app env both set but app_id differs
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
@@ -41,9 +42,13 @@ const (
|
||||
|
||||
// CategoryConfig subtypes
|
||||
const (
|
||||
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
||||
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
||||
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
||||
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
||||
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
||||
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
||||
SubtypeProfileNotFound Subtype = "profile_not_found" // --profile / LARKSUITE_CLI_PROFILE points to a nonexistent profile
|
||||
SubtypeNoActiveProfile Subtype = "no_active_profile" // no active identity input and no usable default profile
|
||||
SubtypeAppCredentialIncomplete Subtype = "app_credential_incomplete" // direct app env missing app_id or app_secret
|
||||
SubtypeProfileSecretInvalid Subtype = "profile_secret_invalid" // profile exists but its secret cannot be resolved locally
|
||||
)
|
||||
|
||||
// CategoryNetwork subtypes
|
||||
|
||||
@@ -61,9 +61,11 @@ type TypedError interface {
|
||||
// it is intentionally not serialized.
|
||||
type ValidationError struct {
|
||||
Problem
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
ProfileAppID string `json:"profile_app_id,omitempty"`
|
||||
EnvAppID string `json:"env_app_id,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// InvalidParam is one structured validation diagnostic: the parameter that
|
||||
@@ -150,6 +152,12 @@ func (e *ValidationError) WithCause(cause error) *ValidationError {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithProfileAppConflict(profileAppID, envAppID string) *ValidationError {
|
||||
e.ProfileAppID = profileAppID
|
||||
e.EnvAppID = envAppID
|
||||
return e
|
||||
}
|
||||
|
||||
// =========================== AuthenticationError =============================
|
||||
|
||||
// AuthenticationError is the typed error for CategoryAuthentication.
|
||||
@@ -315,8 +323,18 @@ func (e *PermissionError) WithCause(cause error) *PermissionError {
|
||||
// intentionally not serialized.
|
||||
type ConfigError struct {
|
||||
Problem
|
||||
Field string `json:"field,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
Field string `json:"field,omitempty"`
|
||||
MissingKeys []string `json:"missing_keys,omitempty"`
|
||||
RequiredAnyOf []string `json:"required_any_of,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
// CredentialSource is the machine-readable App/credential selection source
|
||||
// that produced this config error (e.g. "flag:--profile",
|
||||
// "env:LARKSUITE_CLI_PROFILE", "config"). It is required on
|
||||
// profile_not_found and no_active_profile so an agent can branch
|
||||
// on how the identity was (or was not) chosen. It is never a secret.
|
||||
CredentialSource string `json:"credential_source,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
@@ -370,6 +388,34 @@ func (e *ConfigError) WithField(field string) *ConfigError {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithMissingKeys(keys ...string) *ConfigError {
|
||||
e.MissingKeys = slices.Clone(keys)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithRequiredAnyOf(keys ...string) *ConfigError {
|
||||
e.RequiredAnyOf = slices.Clone(keys)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithProfile(name string) *ConfigError {
|
||||
e.Profile = name
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithAppID(appID string) *ConfigError {
|
||||
e.AppID = appID
|
||||
return e
|
||||
}
|
||||
|
||||
// WithCredentialSource records the machine-readable credential-selection source
|
||||
// on the wire (snake_case credential_source). The value is an enum string
|
||||
// (e.g. "flag:--profile", "config"), never a secret.
|
||||
func (e *ConfigError) WithCredentialSource(source string) *ConfigError {
|
||||
e.CredentialSource = source
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithCause(cause error) *ConfigError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
|
||||
@@ -643,3 +643,29 @@ func TestBuilderSetter_DefensiveCopy(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ======================= Profile selection error subtypes =======================
|
||||
|
||||
func TestConfigErrorProfileFields(t *testing.T) {
|
||||
e := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "incomplete").
|
||||
WithMissingKeys("LARKSUITE_CLI_APP_ID").
|
||||
WithCredentialSource("env:LARKSUITE_CLI_PROFILE")
|
||||
p, ok := errs.ProblemOf(e)
|
||||
if !ok || p.Subtype != errs.SubtypeAppCredentialIncomplete {
|
||||
t.Fatalf("subtype mismatch: %+v", p)
|
||||
}
|
||||
if len(e.MissingKeys) != 1 || e.MissingKeys[0] != "LARKSUITE_CLI_APP_ID" {
|
||||
t.Errorf("missing_keys not set: %v", e.MissingKeys)
|
||||
}
|
||||
if e.CredentialSource != "env:LARKSUITE_CLI_PROFILE" {
|
||||
t.Errorf("credential_source not set: %q", e.CredentialSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrorProfileConflict(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict, "conflict").
|
||||
WithProfileAppConflict("cli_profile", "cli_env")
|
||||
if e.ProfileAppID != "cli_profile" || e.EnvAppID != "cli_env" {
|
||||
t.Errorf("conflict fields not set: %q %q", e.ProfileAppID, e.EnvAppID)
|
||||
}
|
||||
}
|
||||
|
||||
107
events/application/menu.go
Normal file
107
events/application/menu.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// 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 != ""
|
||||
}
|
||||
227
events/application/menu_test.go
Normal file
227
events/application/menu_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// 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
|
||||
}
|
||||
31
events/application/register.go
Normal file
31
events/application/register.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
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"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
application.Keys(),
|
||||
approval.Keys(),
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
|
||||
123
extension/credential/env/env.go
vendored
123
extension/credential/env/env.go
vendored
@@ -23,63 +23,89 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
appSecret := os.Getenv(envvars.CliAppSecret)
|
||||
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
|
||||
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
|
||||
if appID == "" && appSecret == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
case hasTAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
presentKeys := presentCredentialEnvKeys(appID, appSecret, hasUAT, hasTAT)
|
||||
if len(presentKeys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
|
||||
}
|
||||
if appSecret == "" && !hasUAT && !hasTAT {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
}
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
|
||||
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
// Identity policy variables are validated whenever a direct credential
|
||||
// input is present. Their errors must not be hidden by a later credential
|
||||
// completeness check or profile arbitration.
|
||||
defaultAs := credential.Identity(os.Getenv(envvars.CliDefaultAs))
|
||||
switch defaultAs {
|
||||
case "", credential.IdentityAuto, credential.IdentityUser, credential.IdentityBot:
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, defaultAs),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliDefaultAs,
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit strict mode policy takes priority
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
strictMode := os.Getenv(envvars.CliStrictMode)
|
||||
var supported credential.IdentitySupport
|
||||
switch strictMode {
|
||||
case "bot":
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
supported = credential.SupportsBot
|
||||
case "user":
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
supported = credential.SupportsUser
|
||||
case "off":
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
supported = credential.SupportsAll
|
||||
case "":
|
||||
// Infer from available tokens
|
||||
if hasUAT {
|
||||
acct.SupportedIdentities |= credential.SupportsUser
|
||||
supported |= credential.SupportsUser
|
||||
}
|
||||
if hasTAT {
|
||||
acct.SupportedIdentities |= credential.SupportsBot
|
||||
supported |= credential.SupportsBot
|
||||
}
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
if appID == "" && appSecret == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
return nil, incompleteCredentialError(
|
||||
appID,
|
||||
envvars.CliUserAccessToken+" is set but "+envvars.CliAppID+" is missing",
|
||||
[]string{envvars.CliAppID}, nil, presentKeys)
|
||||
case hasTAT:
|
||||
return nil, incompleteCredentialError(
|
||||
appID,
|
||||
envvars.CliTenantAccessToken+" is set but "+envvars.CliAppID+" is missing",
|
||||
[]string{envvars.CliAppID}, nil, presentKeys)
|
||||
}
|
||||
}
|
||||
if appID == "" {
|
||||
return nil, incompleteCredentialError(
|
||||
appID,
|
||||
envvars.CliAppSecret+" is set but "+envvars.CliAppID+" is missing",
|
||||
[]string{envvars.CliAppID}, nil, presentKeys)
|
||||
}
|
||||
if appSecret == "" && !hasUAT && !hasTAT {
|
||||
return nil, incompleteCredentialError(
|
||||
appID,
|
||||
envvars.CliAppID+" is set but no app secret or access token is available",
|
||||
nil,
|
||||
[]string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken},
|
||||
presentKeys)
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
acct := &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: appSecret,
|
||||
Brand: brand,
|
||||
DefaultAs: defaultAs,
|
||||
SupportedIdentities: supported,
|
||||
Kind: credential.AccountDirect,
|
||||
}
|
||||
|
||||
if acct.DefaultAs == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
@@ -92,6 +118,35 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
func incompleteCredentialError(appID, reason string, missingKeys, requiredAnyOf, presentKeys []string) *credential.BlockError {
|
||||
return &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: reason,
|
||||
Code: credential.BlockReasonCredentialIncomplete,
|
||||
MissingKeys: missingKeys,
|
||||
RequiredAnyOf: requiredAnyOf,
|
||||
PresentKeys: presentKeys,
|
||||
AppID: appID,
|
||||
}
|
||||
}
|
||||
|
||||
func presentCredentialEnvKeys(appID, appSecret string, hasUAT, hasTAT bool) []string {
|
||||
var keys []string
|
||||
if appID != "" {
|
||||
keys = append(keys, envvars.CliAppID)
|
||||
}
|
||||
if appSecret != "" {
|
||||
keys = append(keys, envvars.CliAppSecret)
|
||||
}
|
||||
if hasUAT {
|
||||
keys = append(keys, envvars.CliUserAccessToken)
|
||||
}
|
||||
if hasTAT {
|
||||
keys = append(keys, envvars.CliTenantAccessToken)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
var envKey string
|
||||
switch req.Type {
|
||||
|
||||
100
extension/credential/env/env_test.go
vendored
100
extension/credential/env/env_test.go
vendored
@@ -6,6 +6,7 @@ package env
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -47,6 +48,22 @@ func TestResolveAccount_OnlyIDSet(t *testing.T) {
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonCredentialIncomplete {
|
||||
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonCredentialIncomplete)
|
||||
}
|
||||
want := []string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken}
|
||||
if !slices.Equal(blockErr.RequiredAnyOf, want) {
|
||||
t.Fatalf("RequiredAnyOf = %v, want %v", blockErr.RequiredAnyOf, want)
|
||||
}
|
||||
if len(blockErr.MissingKeys) != 0 {
|
||||
t.Fatalf("MissingKeys = %v, want empty", blockErr.MissingKeys)
|
||||
}
|
||||
if !slices.Equal(blockErr.PresentKeys, []string{envvars.CliAppID}) {
|
||||
t.Fatalf("PresentKeys = %v, want [%s]", blockErr.PresentKeys, envvars.CliAppID)
|
||||
}
|
||||
if blockErr.AppID != "cli_test" {
|
||||
t.Fatalf("AppID = %q, want cli_test", blockErr.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
|
||||
@@ -75,18 +92,81 @@ func TestResolveAccount_OnlySecretSet(t *testing.T) {
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonCredentialIncomplete ||
|
||||
!slices.Equal(blockErr.MissingKeys, []string{envvars.CliAppID}) ||
|
||||
!slices.Equal(blockErr.PresentKeys, []string{envvars.CliAppSecret}) {
|
||||
t.Fatalf("BlockError = %+v, want incomplete with missing APP_ID and present APP_SECRET", blockErr)
|
||||
}
|
||||
if len(blockErr.RequiredAnyOf) != 0 {
|
||||
t.Fatalf("RequiredAnyOf = %v, want empty for APP_SECRET-only", blockErr.RequiredAnyOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{name: "UAT", key: envvars.CliUserAccessToken},
|
||||
{name: "TAT", key: envvars.CliTenantAccessToken},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(tt.key, "token_test")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliAppID) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonCredentialIncomplete ||
|
||||
!slices.Equal(blockErr.MissingKeys, []string{envvars.CliAppID}) ||
|
||||
!slices.Equal(blockErr.PresentKeys, []string{tt.key}) {
|
||||
t.Fatalf("BlockError = %+v, want incomplete for %s", blockErr, tt.key)
|
||||
}
|
||||
if len(blockErr.RequiredAnyOf) != 0 {
|
||||
t.Fatalf("RequiredAnyOf = %v, want empty for %s-only", blockErr.RequiredAnyOf, tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliAppID) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidPolicyRejectedBeforeIncomplete(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{name: "DEFAULT_AS", key: envvars.CliDefaultAs},
|
||||
{name: "STRICT_MODE", key: envvars.CliStrictMode},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(tt.key, "banana")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("error = %T %v, want BlockError", err, err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonInvalidPolicy {
|
||||
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonInvalidPolicy)
|
||||
}
|
||||
if blockErr.Param != tt.key {
|
||||
t.Fatalf("Param = %q, want %q", blockErr.Param, tt.key)
|
||||
}
|
||||
if !strings.Contains(blockErr.Reason, tt.key) {
|
||||
t.Fatalf("reason = %q, want %s", blockErr.Reason, tt.key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +338,9 @@ func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonInvalidPolicy || blockErr.Param != envvars.CliStrictMode {
|
||||
t.Fatalf("BlockError = %+v, want invalid_policy with Param %s", blockErr, envvars.CliStrictMode)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
|
||||
}
|
||||
@@ -276,6 +359,9 @@ func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonInvalidPolicy || blockErr.Param != envvars.CliDefaultAs {
|
||||
t.Fatalf("BlockError = %+v, want invalid_policy with Param %s", blockErr, envvars.CliDefaultAs)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliDefaultAs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +94,8 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
@@ -146,6 +148,57 @@ func TestResolveAccount_StrictMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidPolicyClassified(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
supportedText string
|
||||
}{
|
||||
{
|
||||
name: "default as",
|
||||
key: envvars.CliDefaultAs,
|
||||
value: "banana",
|
||||
supportedText: "want user, bot, or auto",
|
||||
},
|
||||
{
|
||||
name: "strict mode",
|
||||
key: envvars.CliStrictMode,
|
||||
value: "banana",
|
||||
supportedText: "want bot, user, or off",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliDefaultAs)
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
setEnv(t, tt.key, tt.value)
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("error = %T %v, want BlockError", err, err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonInvalidPolicy {
|
||||
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonInvalidPolicy)
|
||||
}
|
||||
if blockErr.Param != tt.key {
|
||||
t.Fatalf("Param = %q, want %q", blockErr.Param, tt.key)
|
||||
}
|
||||
if !strings.Contains(blockErr.Reason, tt.key) ||
|
||||
!strings.Contains(blockErr.Reason, tt.value) ||
|
||||
!strings.Contains(blockErr.Reason, tt.supportedText) {
|
||||
t.Fatalf("Reason = %q, want variable, invalid value, and supported values", blockErr.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
|
||||
@@ -44,6 +44,27 @@ func (s IdentitySupport) UserOnly() bool { return s == SupportsUser }
|
||||
// BotOnly returns true if only bot identity is supported.
|
||||
func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
|
||||
|
||||
// AccountKind declares how an account participates in credential arbitration.
|
||||
type AccountKind int
|
||||
|
||||
const (
|
||||
// AccountManaged means the provider owns the whole identity; winning it
|
||||
// ends arbitration outright. The zero value, so existing providers are
|
||||
// unchanged.
|
||||
AccountManaged AccountKind = iota
|
||||
// AccountDirect marks an actively supplied raw credential (the env
|
||||
// provider's LARKSUITE_CLI_* variables). It participates in profile
|
||||
// arbitration and conflict detection instead of winning outright.
|
||||
//
|
||||
// RESERVED: only the builtin env provider may declare AccountDirect
|
||||
// today — the arbitration's direct-credential diagnostics are defined in
|
||||
// terms of the process environment, and the caller rejects AccountDirect
|
||||
// from any other provider. Third-party providers must return
|
||||
// AccountManaged until the SPI carries provider-reported input
|
||||
// descriptors.
|
||||
AccountDirect
|
||||
)
|
||||
|
||||
// Account holds resolved app credentials and configuration.
|
||||
type Account struct {
|
||||
AppID string
|
||||
@@ -53,6 +74,7 @@ type Account struct {
|
||||
ProfileName string
|
||||
OpenID string // optional; if UAT is available, API result takes precedence
|
||||
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
|
||||
Kind AccountKind // AccountManaged (default) or AccountDirect
|
||||
}
|
||||
|
||||
// Token holds a resolved access token and optional metadata.
|
||||
@@ -76,11 +98,38 @@ type TokenSpec struct {
|
||||
AppID string
|
||||
}
|
||||
|
||||
// BlockReason classifies provider-originated block conditions that callers may
|
||||
// safely map to a more specific public error contract.
|
||||
type BlockReason string
|
||||
|
||||
const (
|
||||
// BlockReasonCredentialIncomplete marks incomplete inputs from the builtin
|
||||
// process-env credential provider. It is reserved for that provider because
|
||||
// direct-credential arbitration and diagnostics currently name the fixed
|
||||
// LARKSUITE_CLI_* env surface. Third-party providers must return an
|
||||
// unclassified BlockError until the SPI carries provider-owned input
|
||||
// descriptors. Blocks without a Code propagate unchanged.
|
||||
BlockReasonCredentialIncomplete BlockReason = "credential_incomplete"
|
||||
|
||||
// BlockReasonInvalidPolicy marks a user-supplied policy input (e.g.
|
||||
// LARKSUITE_CLI_DEFAULT_AS / LARKSUITE_CLI_STRICT_MODE) that failed
|
||||
// validation. The caller maps it to a typed validation error carrying
|
||||
// Param and a repair hint, so user input mistakes never surface as
|
||||
// internal errors.
|
||||
BlockReasonInvalidPolicy BlockReason = "invalid_policy"
|
||||
)
|
||||
|
||||
// BlockError is returned by a Provider to actively reject a request
|
||||
// and prevent subsequent providers in the chain from being consulted.
|
||||
type BlockError struct {
|
||||
Provider string
|
||||
Reason string
|
||||
Provider string
|
||||
Reason string
|
||||
Code BlockReason
|
||||
MissingKeys []string // environment variable names only; never values
|
||||
RequiredAnyOf []string // environment variable names only; never values
|
||||
PresentKeys []string // environment variable names only; never values
|
||||
AppID string // plaintext app identifier used only for source comparison; never a secret
|
||||
Param string // name of the invalid input variable on invalid_policy blocks; never a value
|
||||
}
|
||||
|
||||
func (e *BlockError) Error() string {
|
||||
|
||||
23
internal/auth/testmain_test.go
Normal file
23
internal/auth/testmain_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -48,6 +48,18 @@ func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.Token
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
type clientTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r clientTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
|
||||
}
|
||||
|
||||
func newClientTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, clientTestAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
// newTestAPIClient creates an APIClient with a mock HTTP transport.
|
||||
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
@@ -58,7 +70,7 @@ func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Bu
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(httpClient),
|
||||
)
|
||||
testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil)
|
||||
testCred := newClientTestCredentialProvider("test-app", &staticTokenResolver{})
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
return &APIClient{
|
||||
SDK: sdk,
|
||||
@@ -463,7 +475,7 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
|
||||
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Timeout: 5 * time.Millisecond},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -498,7 +510,7 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
|
||||
})
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Transport: rt},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -532,7 +544,7 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
|
||||
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -572,7 +584,7 @@ func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.Tok
|
||||
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil),
|
||||
Credential: newClientTestCredentialProvider("test-app", &needAuthTokenResolver{userOpenID: "ou_test_user"}),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -612,7 +624,7 @@ func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *t
|
||||
func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
|
||||
@@ -132,16 +132,14 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
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()})
|
||||
}
|
||||
|
||||
// Non-JSON (binary) responses.
|
||||
|
||||
@@ -18,6 +18,7 @@ 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"
|
||||
)
|
||||
@@ -239,6 +240,87 @@ 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"})
|
||||
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
// In tests, replace any field to stub out external dependencies.
|
||||
type InvocationContext struct {
|
||||
Profile string
|
||||
// ProfileFromFlag is true when Profile was set via the --profile flag,
|
||||
// and false when it came from the LARKSUITE_CLI_PROFILE env fallback
|
||||
// (or neither was set). Downstream credential resolution uses this to
|
||||
// report the correct profile source.
|
||||
ProfileFromFlag bool
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
|
||||
@@ -22,6 +22,7 @@ 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
|
||||
@@ -33,7 +34,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
|
||||
// Phase 4: LarkClient derived from Credential and workspace policy
|
||||
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
streams = normalizeStreams(streams)
|
||||
f := &Factory{
|
||||
@@ -54,20 +55,22 @@ 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)
|
||||
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
|
||||
|
||||
// Phase 2: Credential (sole data source)
|
||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||
f.Credential = buildCredentialProvider(credentialDeps{
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
ProfileFromFlag: inv.ProfileFromFlag,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
|
||||
// Phase 3: Config derived from Credential via an explicit conversion boundary.
|
||||
// Phase 3: Runtime config contains resolved account data only.
|
||||
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -78,8 +81,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
return cfg, nil
|
||||
})
|
||||
|
||||
// Phase 4: LarkClient from Credential (placeholder AppSecret)
|
||||
f.LarkClient = cachedLarkClientFunc(f)
|
||||
// Phase 4: LarkClient composes account data and workspace policy at the SDK
|
||||
// transport boundary.
|
||||
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
|
||||
|
||||
return f
|
||||
}
|
||||
@@ -108,13 +112,16 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
// .StderrIsTerminal field, which tests set directly.
|
||||
var warnIfProxied = transport.WarnIfProxied
|
||||
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) 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
|
||||
@@ -128,7 +135,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
|
||||
return sync.OnceValues(func() (*lark.Client, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
@@ -142,8 +149,15 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
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: buildSDKTransport(),
|
||||
Transport: sdkTransport,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
@@ -152,9 +166,8 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = transport.Shared()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
@@ -162,10 +175,11 @@ func buildSDKTransport() http.RoundTripper {
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
ProfileFromFlag bool
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
|
||||
@@ -178,5 +192,13 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
|
||||
// depend on. enrichUserInfo failures are already non-fatal (the
|
||||
// provider clears unverified identity fields), so silencing the
|
||||
// warning is safe.
|
||||
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
|
||||
cred := credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
|
||||
if deps.Profile == "" {
|
||||
// No profile selected — don't record a phantom env source.
|
||||
return cred
|
||||
}
|
||||
if deps.ProfileFromFlag {
|
||||
return cred.WithProfileFromFlag(deps.Profile)
|
||||
}
|
||||
return cred.WithProfileFromEnv(deps.Profile)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,15 @@ package cmdutil
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
|
||||
c1, err := fn()
|
||||
if err != nil {
|
||||
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
c, _ := fn()
|
||||
if c.Timeout == 0 {
|
||||
t.Error("expected non-zero timeout")
|
||||
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
|
||||
c, _ := fn()
|
||||
if c.CheckRedirect == nil {
|
||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||
|
||||
@@ -8,6 +8,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -36,13 +37,15 @@ 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)
|
||||
|
||||
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
|
||||
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
|
||||
}})
|
||||
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}})
|
||||
if _, err := fn(); err != nil {
|
||||
t.Fatalf("http client init: %v", err)
|
||||
}
|
||||
@@ -73,7 +76,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)(); err != nil {
|
||||
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
|
||||
t.Fatalf("lark client init: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
@@ -405,6 +406,14 @@ type stubExtProvider struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type stubDefaultAccountResolver struct {
|
||||
acct *credential.Account
|
||||
}
|
||||
|
||||
func (s *stubDefaultAccountResolver) ResolveAccount(_ context.Context) (*credential.Account, error) {
|
||||
return s.acct, nil
|
||||
}
|
||||
|
||||
func (s *stubExtProvider) Name() string { return s.name }
|
||||
func (s *stubExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
|
||||
return s.acct, s.err
|
||||
@@ -448,6 +457,86 @@ func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireBuiltinCredentialProvider_AllowsMatchingAppIDOnlyProfile(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliAppID, "cli_a")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a",
|
||||
AppId: "cli_a",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&envprovider.Provider{}},
|
||||
&stubDefaultAccountResolver{acct: &credential.Account{AppID: "cli_a", AppSecret: "test-secret"}},
|
||||
nil,
|
||||
nil,
|
||||
).WithProfileFromFlag("tenant_a")
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
if err := f.RequireBuiltinCredentialProvider(context.Background(), "auth"); err != nil {
|
||||
t.Fatalf("matching APP_ID-only profile should use builtin credentials: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A stale LARKSUITE_CLI_PROFILE (profile that cannot resolve) must not lock
|
||||
// the user out of the builtin setup/repair commands this gate guards: the
|
||||
// probe falls back to provider engagement and lets the command run.
|
||||
func TestRequireBuiltinCredentialProvider_StaleProfileDoesNotLockOut(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) // no config -> "ghost" cannot resolve
|
||||
|
||||
stub := &stubExtProvider{name: "env"} // not engaged: returns nil, nil
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{stub},
|
||||
&stubDefaultAccountResolver{},
|
||||
nil,
|
||||
nil,
|
||||
).WithProfileFromEnv("ghost")
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
if err := f.RequireBuiltinCredentialProvider(context.Background(), "config"); err != nil {
|
||||
t.Fatalf("stale profile must not lock out builtin auth/config commands: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An invalid policy variable (e.g. LARKSUITE_CLI_DEFAULT_AS=banana) is a user
|
||||
// input error, not an external credential takeover: the gate surfaces the
|
||||
// same typed validation error as formal arbitration instead of a misleading
|
||||
// "provided externally" refusal.
|
||||
func TestRequireBuiltinCredentialProvider_InvalidPolicySurfacesTypedError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
stub := &stubExtProvider{name: "env", err: &extcred.BlockError{
|
||||
Provider: "env",
|
||||
Reason: "invalid LARKSUITE_CLI_DEFAULT_AS \"banana\" (want user, bot, or auto)",
|
||||
Code: extcred.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliDefaultAs,
|
||||
}}
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, &stubDefaultAccountResolver{}, nil, nil)
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok || prob.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("err = %v, want typed invalid_argument (same as formal arbitration)", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "provided externally") {
|
||||
t.Fatalf("err = %v, must not read as external takeover", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = nil
|
||||
|
||||
36
internal/cmdutil/localfile.go
Normal file
36
internal/cmdutil/localfile.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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)
|
||||
}
|
||||
96
internal/cmdutil/localfile_test.go
Normal file
96
internal/cmdutil/localfile_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// 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)
|
||||
}
|
||||
28
internal/cmdutil/risk_control.go
Normal file
28
internal/cmdutil/risk_control.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// 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()
|
||||
}
|
||||
45
internal/cmdutil/risk_control_test.go
Normal file
45
internal/cmdutil/risk_control_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
30
internal/cmdutil/testmain_test.go
Normal file
30
internal/cmdutil/testmain_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ 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)
|
||||
@@ -91,13 +92,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildSDKTransport chain composition
|
||||
// wrapSDKTransport chain composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := buildSDKTransport()
|
||||
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !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 TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
func TestWrapSDKTransport_WithExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := buildSDKTransport()
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !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 TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := buildSDKTransport()
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
if _, ok := ua.Base.(*RetryTransport); !ok {
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -261,6 +277,40 @@ 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
|
||||
@@ -277,7 +327,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 buildSDKTransport.
|
||||
// Replicate the SDK chain layering used by wrapSDKTransport.
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &UserAgentTransport{Base: base}
|
||||
|
||||
@@ -60,11 +60,18 @@ 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 {
|
||||
@@ -248,7 +255,11 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
}
|
||||
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
|
||||
// invalid_config, not not_configured: the config exists but is
|
||||
// internally inconsistent. not_configured would let callers degrade
|
||||
// this into a generic "secret invalid" answer and destroy the precise
|
||||
// repair hint (which names the expected keychain key — never a value).
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "appId and appSecret keychain key are out of sync").
|
||||
WithHint("%s", err.Error()).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
37
internal/core/config_snapshot.go
Normal file
37
internal/core/config_snapshot.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// 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()
|
||||
}
|
||||
58
internal/core/config_snapshot_test.go
Normal file
58
internal/core/config_snapshot_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,9 @@ 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{},
|
||||
@@ -84,6 +86,9 @@ 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) {
|
||||
|
||||
@@ -36,16 +36,13 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
// Surface the real cause (parse error, permission denied, etc.)
|
||||
// so the user can fix the broken file. A malformed file is
|
||||
// invalid_config; anything else (permission denied, etc.) is
|
||||
// not_configured. Both stay on the typed structured-envelope path
|
||||
// at the root command's error sink.
|
||||
subtype := errs.SubtypeNotConfigured
|
||||
if isMalformedConfigError(err) {
|
||||
subtype = errs.SubtypeInvalidConfig
|
||||
}
|
||||
return nil, errs.NewConfigError(subtype, "failed to load config: %v", err).WithCause(err)
|
||||
// Surface the real cause so the user can fix the broken file. Every
|
||||
// non-ENOENT load failure — malformed JSON, permission denied, I/O
|
||||
// error — means a config EXISTS but cannot be used: invalid_config.
|
||||
// Only a genuinely absent config is not_configured; anything else
|
||||
// classified as not_configured would let callers degrade it into
|
||||
// profile_not_found / no_active_profile and hide the real cause.
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
return nil, NotConfiguredError()
|
||||
|
||||
154
internal/credential/authsidecar_contract_test.go
Normal file
154
internal/credential/authsidecar_contract_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package credential_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
sidecarprovider "github.com/larksuite/cli/extension/credential/sidecar"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
func newRealSidecarCredentialProvider(t *testing.T) *credential.CredentialProvider {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
t.Setenv(envvars.CliProxyKey, "test-key")
|
||||
t.Setenv(envvars.CliAppID, "cli_sidecar")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliStrictMode, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
return credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&sidecarprovider.Provider{}},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func TestAuthSidecarInvalidPolicyUsesValidationContract(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{name: "default as", key: envvars.CliDefaultAs},
|
||||
{name: "strict mode", key: envvars.CliStrictMode},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cp := newRealSidecarCredentialProvider(t)
|
||||
t.Setenv(tt.key, "banana")
|
||||
|
||||
_, err := cp.ResolveAccount(context.Background())
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed validation error", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.key {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.key)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, tt.key) {
|
||||
t.Fatalf("hint = %q, want variable name %s", problem.Hint, tt.key)
|
||||
}
|
||||
var blockErr *extcred.BlockError
|
||||
if !errors.As(err, &blockErr) ||
|
||||
blockErr.Code != extcred.BlockReasonInvalidPolicy ||
|
||||
blockErr.Param != tt.key {
|
||||
t.Fatalf("cause = %T %v, want classified BlockError for %s", err, err, tt.key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSidecarGateProbeUsesValidationContract(t *testing.T) {
|
||||
cp := newRealSidecarCredentialProvider(t)
|
||||
t.Setenv(envvars.CliStrictMode, "banana")
|
||||
|
||||
name, err := cp.ActiveExtensionProviderName(context.Background())
|
||||
if name != "" {
|
||||
t.Fatalf("provider name = %q, want empty on invalid policy", name)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed validation error", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want ValidationError", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation ||
|
||||
problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
validationErr.Param != envvars.CliStrictMode {
|
||||
t.Fatalf("problem = %+v param = %q, want validation/invalid_argument param %s", problem, validationErr.Param, envvars.CliStrictMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSidecarTokenHonorsSelectedAppID(t *testing.T) {
|
||||
t.Run("matching app returns sentinel", func(t *testing.T) {
|
||||
cp := newRealSidecarCredentialProvider(t)
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT,
|
||||
AppID: "cli_sidecar",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken: %v", err)
|
||||
}
|
||||
if result == nil || result.Token != sidecar.SentinelUAT {
|
||||
t.Fatalf("result = %+v, want sidecar UAT sentinel", result)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
appID string
|
||||
}{
|
||||
{name: "empty app id", appID: ""},
|
||||
{name: "conflicting app id", appID: "cli_other"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cp := newRealSidecarCredentialProvider(t)
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT,
|
||||
AppID: tt.appID,
|
||||
})
|
||||
if result != nil {
|
||||
t.Fatalf("result = %+v, want no sidecar sentinel", result)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed internal error", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeUnknown)
|
||||
}
|
||||
if strings.Contains(err.Error(), sidecar.SentinelUAT) {
|
||||
t.Fatalf("error leaked sidecar sentinel: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,17 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// DefaultAccountResolver is implemented by the default account provider.
|
||||
@@ -136,10 +142,21 @@ type CredentialProvider struct {
|
||||
httpClient func() (*http.Client, error)
|
||||
warnOut io.Writer
|
||||
|
||||
// profile is the active profile (from --profile or LARKSUITE_CLI_PROFILE);
|
||||
// profileSrc records which of the two supplied it, for the reported
|
||||
// selection and error attribution.
|
||||
profile string
|
||||
profileSrc CredentialSourceKind
|
||||
|
||||
accountOnce sync.Once
|
||||
account *Account
|
||||
accountErr error
|
||||
selectedSource credentialSource
|
||||
// selection is the explainable credential-selection result, populated by
|
||||
// doResolveAccount under accountOnce. It never carries a secret.
|
||||
selection IdentitySelection
|
||||
|
||||
enrichOnce sync.Once
|
||||
|
||||
hintOnce sync.Once
|
||||
hint *IdentityHint
|
||||
@@ -161,49 +178,521 @@ func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
|
||||
return p
|
||||
}
|
||||
|
||||
// WithProfileFromFlag records the --profile flag value as the active profile.
|
||||
// It governs credential arbitration and the reported selection source.
|
||||
func (p *CredentialProvider) WithProfileFromFlag(profile string) *CredentialProvider {
|
||||
p.profile = profile
|
||||
p.profileSrc = SourceFlagProfile
|
||||
return p
|
||||
}
|
||||
|
||||
// WithProfileFromEnv records the LARKSUITE_CLI_PROFILE env fallback as the
|
||||
// active profile. It governs credential arbitration and the reported
|
||||
// selection source.
|
||||
func (p *CredentialProvider) WithProfileFromEnv(profile string) *CredentialProvider {
|
||||
p.profile = profile
|
||||
p.profileSrc = SourceEnvProfile
|
||||
return p
|
||||
}
|
||||
|
||||
// ResolveAccount resolves app credentials. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
||||
// Subsequent calls return the cached result regardless of their context.
|
||||
// This is acceptable for CLI (single invocation per process) but not for long-running servers.
|
||||
func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
acct, err := p.resolveAccountSelection(ctx)
|
||||
if err != nil || acct == nil {
|
||||
return acct, err
|
||||
}
|
||||
if _, ok := p.selectedSource.(extensionTokenSource); ok {
|
||||
p.enrichOnce.Do(func() {
|
||||
p.enrichOrClearIdentity(ctx, acct, p.selectedSource)
|
||||
})
|
||||
}
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// resolveAccountSelection performs and caches only credential selection. It
|
||||
// deliberately does not resolve tokens or user_info, so callers can validate
|
||||
// the selected app before any token work begins.
|
||||
func (p *CredentialProvider) resolveAccountSelection(ctx context.Context) (*Account, error) {
|
||||
p.accountOnce.Do(func() {
|
||||
p.account, p.accountErr = p.doResolveAccount(ctx)
|
||||
})
|
||||
return p.account, p.accountErr
|
||||
}
|
||||
|
||||
// doResolveAccount arbitrates the credential/App selection in three phases:
|
||||
// gather all arbitration inputs in a single I/O pass, decide the route with a
|
||||
// pure function, then execute the remaining I/O for the chosen route.
|
||||
//
|
||||
// Resolution order (encoded in decideIdentity): a managed extension provider
|
||||
// (e.g. sidecar) wins outright; then an explicit profile (--profile /
|
||||
// LARKSUITE_CLI_PROFILE) arbitrates against the direct env credential
|
||||
// (matching app_id → profile supplies credential and tokens; mismatch → hard
|
||||
// conflict; incomplete env without a usable app_id → repair error); then a
|
||||
// complete direct env credential; then the config default (currentApp →
|
||||
// firstApp).
|
||||
//
|
||||
// It populates p.selection (never carries a secret) and p.selectedSource on
|
||||
// every success path.
|
||||
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
||||
in, err := p.gatherIdentityInputs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := decideIdentity(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acct, source, err := p.execute(ctx, d, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.selectedSource = source
|
||||
// Assigned only after full success: error paths can never leave a
|
||||
// partial selection behind.
|
||||
p.selection = d.selection
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// providerAccount pairs an extension-provider account with its token source.
|
||||
type providerAccount struct {
|
||||
acct *Account
|
||||
source extensionTokenSource
|
||||
}
|
||||
|
||||
// identityInputs is one invocation's complete arbitration input, gathered in
|
||||
// a single pass by gatherIdentityInputs. It is read-only after gathering;
|
||||
// decideIdentity consumes it without further I/O.
|
||||
type identityInputs struct {
|
||||
profile string
|
||||
profileSrc CredentialSourceKind
|
||||
|
||||
managed *providerAccount // managed extension account; wins arbitration outright
|
||||
direct *providerAccount // complete direct env credential
|
||||
// directBlock is a provider's explicit incomplete-direct-credential
|
||||
// classification (BlockError.Code == credential_incomplete). It
|
||||
// participates in profile arbitration instead of failing outright.
|
||||
directBlock *extcred.BlockError
|
||||
|
||||
// directKeys / conflictKeys describe the BUILTIN process-env direct
|
||||
// credential surface (LARKSUITE_CLI_* variable NAMES, never values).
|
||||
// They annotate DirectCredentialEnv and conflict hints; a third-party
|
||||
// AccountDirect provider reports its own inputs via BlockError metadata
|
||||
// (PresentKeys/AppID), not through these.
|
||||
directKeys []string
|
||||
conflictKeys []string
|
||||
|
||||
config *core.MultiAppConfig
|
||||
configErr error
|
||||
}
|
||||
|
||||
// gatherIdentityInputs performs the arbitration's read phase: it consults the
|
||||
// extension providers and snapshots the config. Providers classify their own
|
||||
// failures at the source (BlockError.Code); this layer must not infer them by
|
||||
// re-reading environment variables or parsing Reason.
|
||||
func (p *CredentialProvider) gatherIdentityInputs(ctx context.Context) (identityInputs, error) {
|
||||
in := identityInputs{
|
||||
profile: p.profile,
|
||||
profileSrc: p.profileSrc,
|
||||
directKeys: presentDirectCredentialKeys(),
|
||||
conflictKeys: presentDirectCredentialInputKeys(),
|
||||
}
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acct != nil {
|
||||
internal := convertAccount(acct)
|
||||
source := extensionTokenSource{provider: prov}
|
||||
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||
var blockErr *extcred.BlockError
|
||||
if errors.As(err, &blockErr) {
|
||||
switch blockErr.Code {
|
||||
case extcred.BlockReasonCredentialIncomplete:
|
||||
// app_credential_incomplete, profile matching, and
|
||||
// DirectCredentialEnv diagnostics are defined in terms of
|
||||
// the builtin LARKSUITE_CLI_* env surface. Until the SPI
|
||||
// carries provider-owned input descriptors, accepting this
|
||||
// classification from another provider would produce
|
||||
// contradictory arbitration and repair hints.
|
||||
if _, builtin := prov.(*envprovider.Provider); !builtin {
|
||||
return in, newCredentialIncompleteProviderContractError(prov)
|
||||
}
|
||||
in.directBlock = blockErr
|
||||
case extcred.BlockReasonInvalidPolicy:
|
||||
// A user-supplied policy value failed validation; that is
|
||||
// a validation error, never an internal one.
|
||||
return in, newInvalidPolicyError(blockErr)
|
||||
default:
|
||||
// Blocks without a recognized Code preserve their
|
||||
// original attribution.
|
||||
return in, err
|
||||
}
|
||||
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
||||
// (used for strict mode) is already set by the provider.
|
||||
// Clear unverified user identity for safety.
|
||||
internal.UserOpenId = ""
|
||||
internal.UserName = ""
|
||||
break
|
||||
}
|
||||
p.selectedSource = source
|
||||
return internal, nil
|
||||
// Any other provider error preserves its original attribution.
|
||||
return in, err
|
||||
}
|
||||
if acct == nil {
|
||||
continue
|
||||
}
|
||||
pa := &providerAccount{acct: convertAccount(acct), source: extensionTokenSource{provider: prov}}
|
||||
switch acct.Kind {
|
||||
case extcred.AccountDirect:
|
||||
// The arbitration's direct-credential surface — DirectCredentialEnv,
|
||||
// the env:LARKSUITE_CLI_APP_ID selection source, conflict-hint
|
||||
// keys — is defined in terms of the builtin process-env variables.
|
||||
// Until the SPI carries provider-reported input descriptors, only
|
||||
// the builtin env provider may declare AccountDirect; accepting it
|
||||
// from anyone else would produce self-contradictory diagnostics
|
||||
// (e.g. credentialSource "env:LARKSUITE_CLI_APP_ID" with
|
||||
// directCredentialEnv.present=false). The check is by concrete
|
||||
// type: the registry reserves neither names nor uniqueness, so a
|
||||
// Name() comparison would be forgeable.
|
||||
if _, builtin := prov.(*envprovider.Provider); !builtin {
|
||||
return in, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider %q declared AccountDirect, which is reserved for the builtin env provider", prov.Name())
|
||||
}
|
||||
in.direct = pa
|
||||
case extcred.AccountManaged:
|
||||
in.managed = pa
|
||||
default:
|
||||
return in, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider %q returned unknown AccountKind %d", prov.Name(), acct.Kind)
|
||||
}
|
||||
break // the first engaged provider ends the scan (registry priority order)
|
||||
}
|
||||
// The config snapshot backs profile lookup, the config-default route, and
|
||||
// config-default failure attribution. A winning managed or direct-env
|
||||
// identity without a profile never needs it — and managed identities must
|
||||
// keep working when the config is absent or malformed.
|
||||
if in.managed == nil && (in.profile != "" || in.direct == nil) {
|
||||
in.config, in.configErr = core.LoadOrNotConfigured()
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// credentialRoute names which source serves the selected account and tokens.
|
||||
type credentialRoute int
|
||||
|
||||
const (
|
||||
routeManaged credentialRoute = iota
|
||||
routeProfile
|
||||
routeDirectEnv
|
||||
routeConfigDefault
|
||||
)
|
||||
|
||||
// decision is decideIdentity's complete verdict. Nothing in it touched I/O.
|
||||
type decision struct {
|
||||
route credentialRoute
|
||||
selection IdentitySelection
|
||||
// profileAppID is set on routeProfile; app_id is plaintext and safe to
|
||||
// echo in the secret-invalid error.
|
||||
profileAppID string
|
||||
}
|
||||
|
||||
// decideIdentity holds every selection rule in one place: precedence
|
||||
// (managed > profile > direct env > config default), profile/direct-env
|
||||
// conflict detection, and error attribution. It is pure — same inputs, same
|
||||
// verdict — so the full selection matrix is table-testable without env vars
|
||||
// or config fixtures.
|
||||
func decideIdentity(in identityInputs) (decision, error) {
|
||||
// DirectCredentialEnv reports the direct env vars truthfully on every
|
||||
// route: Present always means "direct credential env vars are set".
|
||||
directEnv := DirectCredentialEnv{Present: len(in.directKeys) > 0, Keys: in.directKeys}
|
||||
if in.direct != nil {
|
||||
directEnv.AppID = in.direct.acct.AppID
|
||||
}
|
||||
switch {
|
||||
case in.managed != nil:
|
||||
return decision{route: routeManaged, selection: IdentitySelection{
|
||||
Source: SourceExtension(in.managed.source.Name()),
|
||||
DirectCredentialEnv: directEnv,
|
||||
}}, nil
|
||||
case in.profile != "":
|
||||
return decideProfile(in, directEnv)
|
||||
case in.directBlock != nil:
|
||||
return decision{}, newAppCredentialIncompleteError(in.directBlock, false)
|
||||
case in.direct != nil:
|
||||
return decision{route: routeDirectEnv, selection: IdentitySelection{
|
||||
Source: SourceEnvAppID,
|
||||
DirectCredentialEnv: directEnv,
|
||||
}}, nil
|
||||
default:
|
||||
return decision{route: routeConfigDefault, selection: IdentitySelection{
|
||||
Source: selectionSourceForDefault(in.config),
|
||||
DirectCredentialEnv: directEnv,
|
||||
}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// decideProfile arbitrates an explicit profile against the direct env
|
||||
// credential state.
|
||||
func decideProfile(in identityInputs, directEnv DirectCredentialEnv) (decision, error) {
|
||||
app, err := findProfile(in)
|
||||
if err != nil {
|
||||
return decision{}, err
|
||||
}
|
||||
if in.directBlock != nil {
|
||||
// APP_ID-only is sufficient to compare sources: a matching selected
|
||||
// profile supplies the credential and tokens; a mismatch is the same
|
||||
// hard conflict as a complete direct env. Anything less than a usable
|
||||
// app_id keeps the provider's repair error, extended with the
|
||||
// unset-to-use-the-profile path.
|
||||
if in.directBlock.AppID == "" || !slices.Contains(in.directBlock.PresentKeys, envvars.CliAppID) {
|
||||
return decision{}, newAppCredentialIncompleteError(in.directBlock, true)
|
||||
}
|
||||
if app.AppId != in.directBlock.AppID {
|
||||
return decision{}, newProfileAppCredentialConflict(
|
||||
in.profile, app.AppId, in.directBlock.AppID, in.directBlock.PresentKeys)
|
||||
}
|
||||
directEnv.AppID = in.directBlock.AppID
|
||||
directEnv.Matched = true
|
||||
}
|
||||
if in.direct != nil {
|
||||
// E == complete: the direct env app_id must match the profile.
|
||||
if app.AppId != in.direct.acct.AppID {
|
||||
return decision{}, newProfileAppCredentialConflict(
|
||||
in.profile, app.AppId, in.direct.acct.AppID, in.conflictKeys)
|
||||
}
|
||||
directEnv.Matched = true
|
||||
}
|
||||
return decision{
|
||||
route: routeProfile,
|
||||
selection: IdentitySelection{Source: in.profileSrc, DirectCredentialEnv: directEnv},
|
||||
profileAppID: app.AppId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// findProfile resolves the requested profile against the config snapshot.
|
||||
// A malformed config must surface its real typed cause (invalid_config):
|
||||
// reporting it as profile_not_found would send the user to `profile list`
|
||||
// and hide the broken file. Only a genuinely absent config degrades to
|
||||
// profile_not_found, because the profile then cannot exist anywhere. Both
|
||||
// deliberately outrank an incomplete direct env: fixing the profile side is
|
||||
// what makes the selected profile usable.
|
||||
func findProfile(in identityInputs) (*core.AppConfig, error) {
|
||||
if in.configErr != nil {
|
||||
if prob, ok := errs.ProblemOf(in.configErr); !ok || prob.Subtype != errs.SubtypeNotConfigured {
|
||||
return nil, in.configErr
|
||||
}
|
||||
}
|
||||
if p.defaultAcct != nil {
|
||||
if in.config != nil {
|
||||
if app := in.config.FindApp(in.profile); app != nil {
|
||||
return app, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewConfigError(errs.SubtypeProfileNotFound,
|
||||
"profile %q not found", in.profile).
|
||||
WithProfile(in.profile).
|
||||
WithCredentialSource(string(in.profileSrc)).
|
||||
WithHint("run `lark-cli profile list` to see available profiles.")
|
||||
}
|
||||
|
||||
// execute performs the remaining I/O for the decided route and returns the
|
||||
// account together with its token source.
|
||||
func (p *CredentialProvider) execute(ctx context.Context, d decision, in identityInputs) (*Account, credentialSource, error) {
|
||||
switch d.route {
|
||||
case routeManaged:
|
||||
return in.managed.acct, in.managed.source, nil
|
||||
case routeDirectEnv:
|
||||
return in.direct.acct, in.direct.source, nil
|
||||
case routeProfile:
|
||||
// Resolve the profile's own (keychain-backed) credential locally.
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// A typed failure other than not_configured carries its own
|
||||
// precise, secret-free diagnosis (typed errors never embed secret
|
||||
// material per the error contract) — pass it through instead of
|
||||
// flattening it into the generic secret error. Untyped failures
|
||||
// and a config that vanished mid-resolution stay masked: their
|
||||
// content is not guaranteed secret-free.
|
||||
if prob, ok := errs.ProblemOf(err); ok && prob.Subtype != errs.SubtypeNotConfigured {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, nil, newProfileSecretInvalidError(in.profile, d.profileAppID)
|
||||
}
|
||||
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||
return acct, nil
|
||||
// The resolver re-reads the config; a concurrent profile edit between
|
||||
// gather and here could hand back a different app. Refuse the mismatch
|
||||
// instead of silently using credentials the arbitration never checked.
|
||||
if acct.AppID != d.profileAppID {
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"config changed during resolution: profile %q resolved to a different app", in.profile).
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
return acct, defaultTokenSource{resolver: p.defaultToken}, nil
|
||||
default: // routeConfigDefault
|
||||
if p.defaultAcct == nil {
|
||||
return nil, nil, core.NotConfiguredError()
|
||||
}
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, translateConfigDefaultFailure(err, in.config)
|
||||
}
|
||||
return acct, defaultTokenSource{resolver: p.defaultToken}, nil
|
||||
}
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
// translateConfigDefaultFailure attributes a config-default failure from the
|
||||
// snapshot: a default profile that EXISTS (has an app_id) but whose secret
|
||||
// cannot be resolved locally is profile_secret_invalid — "identity is
|
||||
// configured, its secret is broken" is more actionable than "no active
|
||||
// profile". Only when there is genuinely no usable default profile do we
|
||||
// report no_active_profile. Other typed failures pass through unchanged.
|
||||
func translateConfigDefaultFailure(err error, multi *core.MultiAppConfig) error {
|
||||
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeNotConfigured {
|
||||
return err
|
||||
}
|
||||
if multi != nil {
|
||||
if app := multi.CurrentAppConfig(""); app != nil && app.AppId != "" {
|
||||
return newProfileSecretInvalidError(app.ProfileName(), app.AppId)
|
||||
}
|
||||
}
|
||||
return errs.NewConfigError(errs.SubtypeNoActiveProfile, "no active profile").
|
||||
WithCredentialSource(noActiveProfileCredentialSource).
|
||||
WithHint("run `lark-cli config init` / `lark-cli profile add`, or set %s.", envvars.CliProfile)
|
||||
}
|
||||
|
||||
func newProfileAppCredentialConflict(profile, profileAppID, envAppID string, presentKeys []string) error {
|
||||
err := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict,
|
||||
"profile %q app_id does not match %s", profile, envvars.CliAppID).
|
||||
WithProfileAppConflict(profileAppID, envAppID)
|
||||
if len(presentKeys) > 0 {
|
||||
return err.WithHint("unset %s, or select a profile whose app_id matches the environment.",
|
||||
humanList(presentKeys, "and"))
|
||||
}
|
||||
return err.WithHint("unset the direct credential environment variables, or select a profile whose app_id matches the environment.")
|
||||
}
|
||||
|
||||
func newAppCredentialIncompleteError(blockErr *extcred.BlockError, selectedProfileAvailable bool) *errs.ConfigError {
|
||||
err := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "%s", blockErr.Reason).
|
||||
WithCause(blockErr)
|
||||
if len(blockErr.MissingKeys) > 0 {
|
||||
err.WithMissingKeys(blockErr.MissingKeys...)
|
||||
}
|
||||
if len(blockErr.RequiredAnyOf) > 0 {
|
||||
err.WithRequiredAnyOf(blockErr.RequiredAnyOf...)
|
||||
}
|
||||
|
||||
hint := credentialRepairHint(blockErr)
|
||||
if selectedProfileAvailable && len(blockErr.PresentKeys) > 0 {
|
||||
hint += fmt.Sprintf(", or unset %s to use the selected profile", humanList(blockErr.PresentKeys, "and"))
|
||||
}
|
||||
return err.WithHint("%s.", hint)
|
||||
}
|
||||
|
||||
func credentialRepairHint(blockErr *extcred.BlockError) string {
|
||||
if len(blockErr.RequiredAnyOf) > 0 {
|
||||
return "set " + humanList(blockErr.RequiredAnyOf, "or")
|
||||
}
|
||||
return "set " + humanList(blockErr.MissingKeys, "and")
|
||||
}
|
||||
|
||||
func humanList(items []string, conjunction string) string {
|
||||
switch len(items) {
|
||||
case 0:
|
||||
return "the missing direct credential variables"
|
||||
case 1:
|
||||
return items[0]
|
||||
case 2:
|
||||
return items[0] + " " + conjunction + " " + items[1]
|
||||
default:
|
||||
return strings.Join(items[:len(items)-1], ", ") + ", " + conjunction + " " + items[len(items)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// newInvalidPolicyError translates a provider's invalid-policy block into the
|
||||
// typed validation contract: the failed variable name travels in param, the
|
||||
// repair path in the hint, and the original block stays on the cause chain.
|
||||
// Reason carries only the variable name and its non-secret value.
|
||||
func newInvalidPolicyError(blockErr *extcred.BlockError) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", blockErr.Reason).
|
||||
WithParam(blockErr.Param).
|
||||
WithCause(blockErr).
|
||||
WithHint("set %s to a supported value or unset it.", blockErr.Param)
|
||||
}
|
||||
|
||||
func newCredentialIncompleteProviderContractError(prov extcred.Provider) error {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider %q returned credential_incomplete, which is reserved for the builtin env provider", prov.Name())
|
||||
}
|
||||
|
||||
// newProfileSecretInvalidError is deliberately generic (SECURITY): the
|
||||
// underlying cause may carry secret material, so neither it nor its message
|
||||
// may reach the envelope. app_id is plaintext and safe to echo.
|
||||
func newProfileSecretInvalidError(profile, appID string) error {
|
||||
return errs.NewConfigError(errs.SubtypeProfileSecretInvalid,
|
||||
"profile %q credential could not be resolved locally", profile).
|
||||
WithProfile(profile).
|
||||
WithAppID(appID).
|
||||
WithHint("verify the profile's app secret or re-add the profile with `lark-cli config`.")
|
||||
}
|
||||
|
||||
// enrichOrClearIdentity verifies a provider-supplied user identity via
|
||||
// enrichUserInfo. Verification failure is non-fatal — SupportedIdentities
|
||||
// (used for strict mode) is already set by the provider — but an unverified
|
||||
// identity must not survive it: a stale OpenID would attribute calls to a
|
||||
// user the token can no longer act for.
|
||||
func (p *CredentialProvider) enrichOrClearIdentity(ctx context.Context, acct *Account, source credentialSource) {
|
||||
err := p.enrichUserInfo(ctx, acct, source)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||
}
|
||||
acct.UserOpenId = ""
|
||||
acct.UserName = ""
|
||||
}
|
||||
|
||||
// noActiveProfileCredentialSource is the credential_source reported on the
|
||||
// no_active_profile error. The error contract fixes this to the literal "config": there is
|
||||
// no resolved default profile at all, so the more specific config:currentApp /
|
||||
// config:firstApp source values (used on successful config-default selections)
|
||||
// would be misleading. It is an enum string, never a secret.
|
||||
const noActiveProfileCredentialSource = "config"
|
||||
|
||||
// selectionSourceForDefault reports whether the config default resolved to the
|
||||
// explicit currentApp or fell back to the first app.
|
||||
func selectionSourceForDefault(multi *core.MultiAppConfig) CredentialSourceKind {
|
||||
if multi != nil && multi.CurrentApp != "" {
|
||||
return SourceConfigCurrentApp
|
||||
}
|
||||
return SourceConfigFirstApp
|
||||
}
|
||||
|
||||
// presentDirectCredentialKeys returns the NAMES (never values) of the direct
|
||||
// app credential env vars that are set. Used to annotate DirectCredentialEnv.
|
||||
func presentDirectCredentialKeys() []string {
|
||||
var keys []string
|
||||
if os.Getenv(envvars.CliAppID) != "" {
|
||||
keys = append(keys, envvars.CliAppID)
|
||||
}
|
||||
if os.Getenv(envvars.CliAppSecret) != "" {
|
||||
keys = append(keys, envvars.CliAppSecret)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// presentDirectCredentialInputKeys returns all direct env input names that
|
||||
// must be cleared together to remove a profile/app_id conflict. Values are
|
||||
// never returned.
|
||||
func presentDirectCredentialInputKeys() []string {
|
||||
keys := presentDirectCredentialKeys()
|
||||
if os.Getenv(envvars.CliUserAccessToken) != "" {
|
||||
keys = append(keys, envvars.CliUserAccessToken)
|
||||
}
|
||||
if os.Getenv(envvars.CliTenantAccessToken) != "" {
|
||||
keys = append(keys, envvars.CliTenantAccessToken)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Selection resolves the account (once) and returns the cached, secret-free
|
||||
// explanation of how the credential/App was selected. It mirrors
|
||||
// selectedCredentialSource: resolve-then-return.
|
||||
func (p *CredentialProvider) Selection(ctx context.Context) (IdentitySelection, error) {
|
||||
if _, err := p.ResolveAccount(ctx); err != nil {
|
||||
return IdentitySelection{}, err
|
||||
}
|
||||
return p.selection, nil
|
||||
}
|
||||
|
||||
// enrichUserInfo resolves user identity when extension provides a UAT.
|
||||
@@ -239,17 +728,13 @@ func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account,
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
|
||||
if p.selectedSource != nil {
|
||||
return p.selectedSource, nil
|
||||
}
|
||||
if p.defaultAcct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := p.ResolveAccount(ctx); err != nil {
|
||||
if _, err := p.resolveAccountSelection(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.selectedSource == nil {
|
||||
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider resolved an account without selecting a token source").
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
return p.selectedSource, nil
|
||||
}
|
||||
@@ -302,51 +787,88 @@ func (p *CredentialProvider) doResolveIdentityHint(ctx context.Context) (*Identi
|
||||
|
||||
// ResolveToken resolves an access token.
|
||||
func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
source, err := p.selectedCredentialSource(ctx)
|
||||
acct, err := p.resolveAccountSelection(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if source != nil {
|
||||
return resolveTokenFromSource(ctx, source, req)
|
||||
if acct == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider resolved no account before %s token resolution", req.Type).
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
|
||||
for _, prov := range p.providers {
|
||||
source := extensionTokenSource{provider: prov}
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return result, nil
|
||||
}
|
||||
source := p.selectedSource
|
||||
if source == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider resolved app %q without selecting a token source", acct.AppID).
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
source = defaultTokenSource{resolver: p.defaultToken}
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if req.AppID == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"TokenSpec.AppID is required for %s token resolution", req.Type).
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
if found {
|
||||
return result, nil
|
||||
if req.AppID != acct.AppID {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"token requested for app %q but the selected account belongs to app %q", req.AppID, acct.AppID).
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
return nil, &TokenUnavailableError{Type: req.Type}
|
||||
return resolveTokenFromSource(ctx, source, req)
|
||||
}
|
||||
|
||||
// ActiveExtensionProviderName reports whether an extension provider is managing
|
||||
// credentials. It probes p.providers (extension providers only, not defaultAcct)
|
||||
// and returns the name of the first engaged provider.
|
||||
// the credentials that actually win selection. With an explicit profile that
|
||||
// resolves successfully it reuses ResolveAccount's cached arbitration result;
|
||||
// otherwise it probes extension providers directly and returns the first
|
||||
// engaged provider.
|
||||
//
|
||||
// "Engaged" means: ResolveAccount returns a non-nil account, OR returns a
|
||||
// *extcred.BlockError (provider configured but misconfigured — still counts as
|
||||
// external). Any other error is propagated to the caller.
|
||||
// external). Any other probe error is propagated to the caller.
|
||||
//
|
||||
// A failed profile resolution (profile not found, broken secret, malformed
|
||||
// config, incomplete direct env, ...) deliberately does NOT propagate: this
|
||||
// probe guards the builtin setup/repair commands (auth, config), and an
|
||||
// unresolvable credential must never lock the user out of the commands that
|
||||
// fix it. It falls back to the engagement probe, which answers the only
|
||||
// question this function owns: is an extension provider holding credentials?
|
||||
//
|
||||
// Returns ("", nil) when no extension provider is active (built-in keychain path).
|
||||
// Safe to call multiple times — probes providers directly without the sync.Once cache.
|
||||
// Safe to call multiple times: explicit-profile resolution uses sync.Once, while
|
||||
// the probe path only consults providers.
|
||||
func (p *CredentialProvider) ActiveExtensionProviderName(ctx context.Context) (string, error) {
|
||||
// With an explicit profile, report the source that actually won the same
|
||||
// arbitration used by commands. A matching APP_ID-only env block is not an
|
||||
// external takeover once the selected profile supplies credentials/tokens.
|
||||
if p.profile != "" {
|
||||
if _, err := p.ResolveAccount(ctx); err == nil {
|
||||
if p.selectedSource == nil {
|
||||
return "", nil
|
||||
}
|
||||
if _, builtin := p.selectedSource.(defaultTokenSource); builtin {
|
||||
return "", nil
|
||||
}
|
||||
return p.selectedSource.Name(), nil
|
||||
}
|
||||
// Resolution failed — fall through to the engagement probe.
|
||||
}
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
var blockErr *extcred.BlockError
|
||||
if errors.As(err, &blockErr) {
|
||||
// Align with formal arbitration: a misconfigured policy
|
||||
// variable is the same typed validation error everywhere —
|
||||
// not an external takeover of the provider that reported it,
|
||||
// and not license to keep scanning and blame a later
|
||||
// provider instead.
|
||||
if blockErr.Code == extcred.BlockReasonInvalidPolicy {
|
||||
return "", newInvalidPolicyError(blockErr)
|
||||
}
|
||||
if blockErr.Code == extcred.BlockReasonCredentialIncomplete {
|
||||
if _, builtin := prov.(*envprovider.Provider); !builtin {
|
||||
return "", newCredentialIncompleteProviderContractError(prov)
|
||||
}
|
||||
}
|
||||
name := blockErr.Provider
|
||||
if name == "" {
|
||||
name = prov.Name()
|
||||
|
||||
1124
internal/credential/credential_provider_selection_test.go
Normal file
1124
internal/credential/credential_provider_selection_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -23,6 +24,7 @@ type mockExtProvider struct {
|
||||
err error
|
||||
accountErr error
|
||||
tokenErr error
|
||||
tokenCalls int
|
||||
}
|
||||
|
||||
func (m *mockExtProvider) Name() string { return m.name }
|
||||
@@ -33,6 +35,7 @@ func (m *mockExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account,
|
||||
return m.account, m.err
|
||||
}
|
||||
func (m *mockExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
|
||||
m.tokenCalls++
|
||||
if m.tokenErr != nil {
|
||||
return nil, m.tokenErr
|
||||
}
|
||||
@@ -49,11 +52,13 @@ func (m *mockDefaultAcct) ResolveAccount(ctx context.Context) (*Account, error)
|
||||
}
|
||||
|
||||
type mockDefaultToken struct {
|
||||
result *TokenResult
|
||||
err error
|
||||
result *TokenResult
|
||||
err error
|
||||
tokenCalls int
|
||||
}
|
||||
|
||||
func (m *mockDefaultToken) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
m.tokenCalls++
|
||||
return m.result, m.err
|
||||
}
|
||||
|
||||
@@ -116,35 +121,45 @@ func TestCredentialProvider_AccountCached(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenFromExtension(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: "env"},
|
||||
}},
|
||||
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "ext_tok" {
|
||||
t.Errorf("expected ext_tok, got %s", result.Token)
|
||||
for _, sourceName := range []string{"env", "authsidecar"} {
|
||||
t.Run(sourceName, func(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: sourceName,
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: sourceName},
|
||||
}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "ext_tok" {
|
||||
t.Errorf("expected ext_tok, got %s", result.Token)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenFallsToDefault(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "skip"}},
|
||||
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
defaultToken, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "default_tok" {
|
||||
t.Errorf("expected default_tok, got %s", result.Token)
|
||||
}
|
||||
if defaultToken.tokenCalls != 1 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 1", defaultToken.tokenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t *testing.T) {
|
||||
@@ -159,7 +174,7 @@ func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken() error = %v", err)
|
||||
}
|
||||
@@ -181,7 +196,7 @@ func TestCredentialProvider_SelectedSourceWithoutTokenReturnsUnavailableError(t
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want unavailable error")
|
||||
}
|
||||
@@ -202,7 +217,7 @@ func TestCredentialProvider_ResolveTokenPropagatesNonBlockExtensionError(t *test
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
if err == nil || err.Error() != "provider exploded" {
|
||||
t.Fatalf("ResolveToken() error = %v, want provider exploded", err)
|
||||
}
|
||||
@@ -312,12 +327,12 @@ func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) {
|
||||
func TestCredentialProvider_ResolveTokenTreatsEmptyDefaultTokenAsMalformed(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: ""}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty token") {
|
||||
t.Fatalf("ResolveToken() error = %v, want malformed empty token error", err)
|
||||
}
|
||||
@@ -410,17 +425,189 @@ func TestCredentialProvider_ResolveAccountWarnsWhenExtensionIdentityVerification
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenDoesNotBypassFailedDefaultAccountResolution(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{err: errors.New("config unavailable")},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
defaultToken,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
if err == nil || err.Error() != "config unavailable" {
|
||||
t.Fatalf("ResolveToken() error = %v, want config unavailable", err)
|
||||
}
|
||||
if defaultToken.tokenCalls != 0 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenRejectsUnboundAppBeforeExtensionIO(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appID string
|
||||
}{
|
||||
{name: "empty app id"},
|
||||
{name: "different app id", appID: "other_app"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, sourceName := range []string{"env", "authsidecar"} {
|
||||
t.Run(tt.name+"/"+sourceName, func(t *testing.T) {
|
||||
provider := &mockExtProvider{
|
||||
name: sourceName,
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: sourceName},
|
||||
}
|
||||
httpClientCalls := 0
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{provider},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
func() (*http.Client, error) {
|
||||
httpClientCalls++
|
||||
return nil, errors.New("unexpected user_info call")
|
||||
},
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: tt.appID})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want app binding error")
|
||||
}
|
||||
assertInternalUnknownWithRetryHint(t, err)
|
||||
if provider.tokenCalls != 0 {
|
||||
t.Fatalf("extension ResolveToken() calls = %d, want 0", provider.tokenCalls)
|
||||
}
|
||||
if httpClientCalls != 0 {
|
||||
t.Fatalf("httpClient() calls = %d, want 0", httpClientCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenRejectsUnboundAppBeforeDefaultIO(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appID string
|
||||
}{
|
||||
{name: "empty app id"},
|
||||
{name: "different app id", appID: "other_app"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
defaultToken,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: tt.appID})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want app binding error")
|
||||
}
|
||||
assertInternalUnknownWithRetryHint(t, err)
|
||||
if defaultToken.tokenCalls != 0 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenRejectsNilAccountBeforeTokenIO(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{},
|
||||
defaultToken,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "requested_app"})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want nil account error")
|
||||
}
|
||||
assertInternalUnknownWithRetryHint(t, err)
|
||||
if defaultToken.tokenCalls != 0 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenRejectsMissingSelectedSourceWithoutFallback(t *testing.T) {
|
||||
extension := &mockExtProvider{
|
||||
name: "env",
|
||||
token: &extcred.Token{Value: "ext_tok", Source: "env"},
|
||||
}
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{extension},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
defaultToken,
|
||||
nil,
|
||||
)
|
||||
cp.account = &Account{AppID: "selected_app"}
|
||||
cp.accountOnce.Do(func() {})
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "selected_app"})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want missing selected source error")
|
||||
}
|
||||
assertInternalUnknownWithRetryHint(t, err)
|
||||
if extension.tokenCalls != 0 {
|
||||
t.Fatalf("extension ResolveToken() calls = %d, want 0", extension.tokenCalls)
|
||||
}
|
||||
if defaultToken.tokenCalls != 0 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenMatchingExtensionDoesNotEnrichIdentity(t *testing.T) {
|
||||
provider := &mockExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: "env"},
|
||||
}
|
||||
httpClientCalls := 0
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{provider},
|
||||
nil,
|
||||
nil,
|
||||
func() (*http.Client, error) {
|
||||
httpClientCalls++
|
||||
return nil, errors.New("unexpected user_info call")
|
||||
},
|
||||
)
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken() error = %v", err)
|
||||
}
|
||||
if result.Token != "ext_tok" {
|
||||
t.Fatalf("ResolveToken() token = %q, want %q", result.Token, "ext_tok")
|
||||
}
|
||||
if provider.tokenCalls != 1 {
|
||||
t.Fatalf("extension ResolveToken() calls = %d, want 1", provider.tokenCalls)
|
||||
}
|
||||
if httpClientCalls != 0 {
|
||||
t.Fatalf("httpClient() calls = %d, want 0", httpClientCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func assertInternalUnknownWithRetryHint(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed internal error", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("error problem = %+v, want internal/unknown", problem)
|
||||
}
|
||||
if problem.Hint != "retry the command." {
|
||||
t.Fatalf("error hint = %q, want retry hint", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveExtensionProviderName_ExtActive(t *testing.T) {
|
||||
|
||||
181
internal/credential/decide_test.go
Normal file
181
internal/credential/decide_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// stubDecideProvider satisfies extcred.Provider for building providerAccount
|
||||
// literals; decideIdentity only ever calls Name() on it.
|
||||
type stubDecideProvider struct{ name string }
|
||||
|
||||
func (s stubDecideProvider) Name() string { return s.name }
|
||||
func (s stubDecideProvider) Priority() int { return 0 }
|
||||
func (s stubDecideProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s stubDecideProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func pa(providerName, appID string) *providerAccount {
|
||||
return &providerAccount{
|
||||
acct: &Account{AppID: appID},
|
||||
source: extensionTokenSource{provider: stubDecideProvider{name: providerName}},
|
||||
}
|
||||
}
|
||||
|
||||
func appIDOnlyBlock(appID string) *extcred.BlockError {
|
||||
return &extcred.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
Code: extcred.BlockReasonCredentialIncomplete,
|
||||
RequiredAnyOf: []string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken},
|
||||
PresentKeys: []string{envvars.CliAppID},
|
||||
AppID: appID,
|
||||
}
|
||||
}
|
||||
|
||||
func uatOnlyBlock() *extcred.BlockError {
|
||||
return &extcred.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing",
|
||||
Code: extcred.BlockReasonCredentialIncomplete,
|
||||
MissingKeys: []string{envvars.CliAppID},
|
||||
PresentKeys: []string{envvars.CliUserAccessToken},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecideIdentity exercises the selection matrix as data: decideIdentity is
|
||||
// pure, so every rule (precedence, conflict detection, error attribution) is
|
||||
// table-testable without env vars or config fixtures.
|
||||
func TestDecideIdentity(t *testing.T) {
|
||||
tenantA := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{Name: "tenant_a", AppId: "cli_a"}},
|
||||
}
|
||||
noCurrent := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{Name: "tenant_a", AppId: "cli_a"}},
|
||||
}
|
||||
invalidConfigErr := errs.NewConfigError(errs.SubtypeInvalidConfig, "invalid config format")
|
||||
notConfiguredErr := core.NotConfiguredError()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in identityInputs
|
||||
route credentialRoute
|
||||
source CredentialSourceKind
|
||||
matched bool
|
||||
subtype errs.Subtype // "" = success expected
|
||||
}{
|
||||
{
|
||||
name: "managed provider wins over explicit profile",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, managed: pa("sidecar", "sidecar_app"), config: tenantA},
|
||||
route: routeManaged,
|
||||
source: SourceExtension("sidecar"),
|
||||
},
|
||||
{
|
||||
name: "profile conflicts with complete direct env app_id",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, direct: pa("env", "cli_x"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}, config: tenantA},
|
||||
subtype: errs.SubtypeProfileAppCredentialConflict,
|
||||
},
|
||||
{
|
||||
name: "matched complete direct env yields profile route",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceEnvProfile, direct: pa("env", "cli_a"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}, config: tenantA},
|
||||
route: routeProfile,
|
||||
source: SourceEnvProfile,
|
||||
matched: true,
|
||||
},
|
||||
{
|
||||
name: "APP_ID-only block matching the profile yields profile route",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}, config: tenantA},
|
||||
route: routeProfile,
|
||||
source: SourceFlagProfile,
|
||||
matched: true,
|
||||
},
|
||||
{
|
||||
name: "APP_ID-only block mismatching the profile is a hard conflict",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: appIDOnlyBlock("cli_x"), directKeys: []string{envvars.CliAppID}, config: tenantA},
|
||||
subtype: errs.SubtypeProfileAppCredentialConflict,
|
||||
},
|
||||
{
|
||||
name: "UAT-only block with a valid profile keeps the repair error",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: uatOnlyBlock(), config: tenantA},
|
||||
subtype: errs.SubtypeAppCredentialIncomplete,
|
||||
},
|
||||
{
|
||||
name: "block without profile is app_credential_incomplete",
|
||||
in: identityInputs{directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}},
|
||||
subtype: errs.SubtypeAppCredentialIncomplete,
|
||||
},
|
||||
{
|
||||
name: "complete direct env without profile wins",
|
||||
in: identityInputs{direct: pa("env", "cli_env"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}},
|
||||
route: routeDirectEnv,
|
||||
source: SourceEnvAppID,
|
||||
},
|
||||
{
|
||||
name: "malformed config is not masked as profile_not_found",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, configErr: invalidConfigErr},
|
||||
subtype: errs.SubtypeInvalidConfig,
|
||||
},
|
||||
{
|
||||
name: "absent config degrades to profile_not_found",
|
||||
in: identityInputs{profile: "ghost", profileSrc: SourceEnvProfile, configErr: notConfiguredErr},
|
||||
subtype: errs.SubtypeProfileNotFound,
|
||||
},
|
||||
{
|
||||
name: "profile missing from a valid config is profile_not_found even with incomplete env",
|
||||
in: identityInputs{profile: "ghost", profileSrc: SourceEnvProfile, directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}, config: tenantA},
|
||||
subtype: errs.SubtypeProfileNotFound,
|
||||
},
|
||||
{
|
||||
name: "config default reports currentApp",
|
||||
in: identityInputs{config: tenantA},
|
||||
route: routeConfigDefault,
|
||||
source: SourceConfigCurrentApp,
|
||||
},
|
||||
{
|
||||
name: "config default without currentApp reports firstApp",
|
||||
in: identityInputs{config: noCurrent},
|
||||
route: routeConfigDefault,
|
||||
source: SourceConfigFirstApp,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d, err := decideIdentity(tc.in)
|
||||
if tc.subtype != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("decideIdentity = %+v, want error subtype %q", d, tc.subtype)
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok || prob.Subtype != tc.subtype {
|
||||
t.Fatalf("error = %v, want subtype %q", err, tc.subtype)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("decideIdentity: %v", err)
|
||||
}
|
||||
if d.route != tc.route {
|
||||
t.Errorf("route = %d, want %d", d.route, tc.route)
|
||||
}
|
||||
if d.selection.Source != tc.source {
|
||||
t.Errorf("source = %q, want %q", d.selection.Source, tc.source)
|
||||
}
|
||||
if d.selection.DirectCredentialEnv.Matched != tc.matched {
|
||||
t.Errorf("matched = %v, want %v", d.selection.DirectCredentialEnv.Matched, tc.matched)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -74,9 +74,13 @@ func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string
|
||||
|
||||
func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
// Load config once — used for both credentials and strict mode.
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
// LoadOrNotConfigured distinguishes an absent config (→ not_configured)
|
||||
// from a malformed/unreadable one (→ invalid_config with cause), so a
|
||||
// broken config is never masked as "run config init" — matching the
|
||||
// explicit-profile path in doResolveAccount.
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return nil, core.NotConfiguredError()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
||||
@@ -116,6 +120,7 @@ type DefaultTokenProvider struct {
|
||||
|
||||
tatOnce sync.Once
|
||||
tatResult *TokenResult
|
||||
tatAppID string
|
||||
tatErr error
|
||||
}
|
||||
|
||||
@@ -126,21 +131,42 @@ func NewDefaultTokenProvider(defaultAcct *DefaultAccountProvider, httpClient fun
|
||||
func (p *DefaultTokenProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
switch req.Type {
|
||||
case TokenTypeUAT:
|
||||
return p.resolveUAT(ctx)
|
||||
return p.resolveUAT(ctx, req)
|
||||
case TokenTypeTAT:
|
||||
return p.resolveTAT(ctx)
|
||||
return p.resolveTAT(ctx, req)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported token type: %s", req.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTokenAppID refuses to hand out a token for a different app than the
|
||||
// caller resolved. The token provider re-reads the config, so a concurrent
|
||||
// profile edit between account resolution and token resolution could otherwise
|
||||
// cross tokens between apps. TokenSpec.AppID is REQUIRED here: an empty value
|
||||
// would silently disable the guarantee, so it is rejected rather than skipped.
|
||||
func checkTokenAppID(req TokenSpec, resolvedAppID string) error {
|
||||
if req.AppID == "" {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"TokenSpec.AppID is required for %s token resolution", req.Type)
|
||||
}
|
||||
if req.AppID == resolvedAppID {
|
||||
return nil
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"config changed during resolution: token requested for app %q but the saved profile now resolves to a different app", req.AppID).
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
|
||||
// resolveUAT resolves a user access token. Not cached (unlike TAT) because UAT
|
||||
// may be refreshed between calls and GetValidAccessToken handles its own caching.
|
||||
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, error) {
|
||||
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkTokenAppID(req, acct.AppID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient, err := p.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -157,20 +183,36 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
|
||||
return &TokenResult{Token: token, Scopes: scopes}, nil
|
||||
}
|
||||
|
||||
// resolveTAT resolves a tenant access token. The result is cached after the first
|
||||
// call via sync.Once — only the context from the first call is used.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
||||
})
|
||||
return p.tatResult, p.tatErr
|
||||
}
|
||||
|
||||
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
// resolveTAT resolves a tenant access token. The result is cached after the
|
||||
// first mint via sync.Once — only the context from that call is used.
|
||||
//
|
||||
// The account is resolved and checked against the request BEFORE any token
|
||||
// work: a mismatched request must not trigger a token mint (network call,
|
||||
// quota, audit trail) for the wrong app. The cached result is additionally
|
||||
// re-checked on every hit, so a token minted for one app is never served to
|
||||
// a request that resolved another.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkTokenAppID(req, acct.AppID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx, acct)
|
||||
p.tatAppID = acct.AppID
|
||||
})
|
||||
if p.tatErr != nil {
|
||||
return nil, p.tatErr
|
||||
}
|
||||
if err := checkTokenAppID(req, p.tatAppID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.tatResult, nil
|
||||
}
|
||||
|
||||
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context, acct *Account) (*TokenResult, error) {
|
||||
httpClient, err := p.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
|
||||
@@ -92,3 +97,136 @@ func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
|
||||
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTokenAppID(t *testing.T) {
|
||||
if err := checkTokenAppID(TokenSpec{Type: TokenTypeUAT}, "cli_a"); err == nil {
|
||||
t.Fatal("empty requested app must be rejected: it would silently disable the guarantee")
|
||||
}
|
||||
if err := checkTokenAppID(TokenSpec{AppID: "cli_a"}, "cli_a"); err != nil {
|
||||
t.Fatalf("matching app must pass: %v", err)
|
||||
}
|
||||
err := checkTokenAppID(TokenSpec{AppID: "cli_a"}, "cli_b")
|
||||
if err == nil {
|
||||
t.Fatal("mismatched app must be refused")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("error type = %T, want *errs.InternalError", err)
|
||||
}
|
||||
}
|
||||
|
||||
// REAL-path regression for review F2: the token provider re-reads the config,
|
||||
// so a profile edit between account resolution and token resolution must not
|
||||
// hand a token minted for the new app to a caller that resolved the old one.
|
||||
// Uses the real DefaultAccountProvider + DefaultTokenProvider; the HTTP stub
|
||||
// makes the network step unreachable, so reaching it proves the app check ran
|
||||
// and passed first.
|
||||
func TestDefaultTokenProvider_RefusesTokenAfterConfigSwap(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeCfg := func(appID string) {
|
||||
t.Helper()
|
||||
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
|
||||
Name: "tenant_a", AppId: appID, AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
}
|
||||
writeCfg("cli_a")
|
||||
|
||||
httpSentinel := errors.New("http client sentinel: unreachable in test")
|
||||
tp := NewDefaultTokenProvider(
|
||||
NewDefaultAccountProvider(nil, "tenant_a"),
|
||||
func() (*http.Client, error) { return nil, httpSentinel },
|
||||
nil,
|
||||
)
|
||||
|
||||
// Matching app: the consistency check passes and resolution proceeds to
|
||||
// the (stubbed) HTTP step.
|
||||
_, err := tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "cli_a"})
|
||||
if !errors.Is(err, httpSentinel) {
|
||||
t.Fatalf("err = %v, want the HTTP sentinel (check must pass for a matching app)", err)
|
||||
}
|
||||
|
||||
// The profile now resolves to a different app: the token request that was
|
||||
// arbitrated for cli_a must be refused before any token work happens.
|
||||
writeCfg("cli_b")
|
||||
_, err = tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "cli_a"})
|
||||
if err == nil || !strings.Contains(err.Error(), "config changed during resolution") {
|
||||
t.Fatalf("err = %v, want config-changed refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// F1 regression: a TAT request for a mismatched app must be refused BEFORE
|
||||
// any token work starts — no HTTP client construction, no mint, no cache —
|
||||
// otherwise the CLI mints (and caches) a token for the wrong app and only
|
||||
// then refuses to return it, leaving auth audit/quota side effects behind.
|
||||
func TestDefaultTokenProvider_TATChecksAppBeforeAnyTokenWork(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
|
||||
Name: "tenant_a", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
httpCalled := false
|
||||
tp := NewDefaultTokenProvider(
|
||||
NewDefaultAccountProvider(nil, "tenant_a"),
|
||||
func() (*http.Client, error) { httpCalled = true; return nil, errors.New("http sentinel") },
|
||||
nil,
|
||||
)
|
||||
|
||||
// The profile resolves to cli_b, but the caller arbitrated cli_a.
|
||||
_, err := tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeTAT, AppID: "cli_a"})
|
||||
if err == nil || !strings.Contains(err.Error(), "config changed during resolution") {
|
||||
t.Fatalf("err = %v, want config-changed refusal", err)
|
||||
}
|
||||
if httpCalled {
|
||||
t.Fatal("token work started for a mismatched app: the check must run before any HTTP client is built")
|
||||
}
|
||||
}
|
||||
|
||||
// countingTATTripper serves a canned successful TAT response and counts calls.
|
||||
type countingTATTripper struct{ calls int }
|
||||
|
||||
func (c *countingTATTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
c.calls++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":0,"access_token":"your-access-token"}`)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TAT happy path: the first request mints the token over HTTP, the second is
|
||||
// served from the sync.Once cache without another HTTP call.
|
||||
func TestDefaultTokenProvider_TATSuccessAndCacheHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
|
||||
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
tripper := &countingTATTripper{}
|
||||
tp := NewDefaultTokenProvider(
|
||||
NewDefaultAccountProvider(nil, "tenant_a"),
|
||||
func() (*http.Client, error) { return &http.Client{Transport: tripper}, nil },
|
||||
nil,
|
||||
)
|
||||
|
||||
req := TokenSpec{Type: TokenTypeTAT, AppID: "cli_a"}
|
||||
first, err := tp.ResolveToken(context.Background(), req)
|
||||
if err != nil || first.Token != "your-access-token" {
|
||||
t.Fatalf("first resolve = %+v, %v; want minted token", first, err)
|
||||
}
|
||||
second, err := tp.ResolveToken(context.Background(), req)
|
||||
if err != nil || second.Token != "your-access-token" {
|
||||
t.Fatalf("second resolve = %+v, %v; want cached token", second, err)
|
||||
}
|
||||
if tripper.calls != 1 {
|
||||
t.Fatalf("HTTP calls = %d, want exactly 1 (second resolve must hit the cache)", tripper.calls)
|
||||
}
|
||||
}
|
||||
|
||||
54
internal/credential/identity_selection.go
Normal file
54
internal/credential/identity_selection.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
// CredentialSourceKind is the wire-stable App/credential selection source.
|
||||
type CredentialSourceKind string
|
||||
|
||||
const (
|
||||
SourceFlagProfile CredentialSourceKind = "flag:--profile"
|
||||
SourceEnvProfile CredentialSourceKind = "env:LARKSUITE_CLI_PROFILE"
|
||||
SourceEnvAppID CredentialSourceKind = "env:LARKSUITE_CLI_APP_ID"
|
||||
SourceConfigCurrentApp CredentialSourceKind = "config:currentApp"
|
||||
SourceConfigFirstApp CredentialSourceKind = "config:firstApp"
|
||||
|
||||
// SourceExtensionPrefix prefixes the name of a managed extension provider
|
||||
// that won selection outright (e.g. "extension:sidecar"). With it, an
|
||||
// empty Source is left with exactly one meaning: not resolved.
|
||||
SourceExtensionPrefix CredentialSourceKind = "extension:"
|
||||
)
|
||||
|
||||
// SourceExtension reports the selection source for a managed extension
|
||||
// provider by name.
|
||||
func SourceExtension(name string) CredentialSourceKind {
|
||||
return SourceExtensionPrefix + CredentialSourceKind(name)
|
||||
}
|
||||
|
||||
// DirectCredentialEnv describes the state of direct app credential env vars.
|
||||
// It never carries a secret value — only names and the non-sensitive app_id.
|
||||
type DirectCredentialEnv struct {
|
||||
Present bool `json:"present"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
AppID string `json:"appId,omitempty"`
|
||||
Matched bool `json:"matched,omitempty"`
|
||||
ConflictsWithProfile bool `json:"conflictsWithProfile,omitempty"`
|
||||
}
|
||||
|
||||
// IdentitySelection is the explainable result of credential selection.
|
||||
// It carries NO secret value.
|
||||
type IdentitySelection struct {
|
||||
Source CredentialSourceKind
|
||||
DirectCredentialEnv DirectCredentialEnv
|
||||
}
|
||||
|
||||
// Explicit reports whether the identity was actively specified by the
|
||||
// user/agent (flag or env), which governs no-fallback behavior.
|
||||
func (s IdentitySelection) Explicit() bool {
|
||||
switch s.Source {
|
||||
case SourceFlagProfile, SourceEnvProfile, SourceEnvAppID:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
25
internal/credential/identity_selection_test.go
Normal file
25
internal/credential/identity_selection_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIdentitySelectionExplicit(t *testing.T) {
|
||||
cases := []struct {
|
||||
src CredentialSourceKind
|
||||
explicit bool
|
||||
}{
|
||||
{SourceFlagProfile, true},
|
||||
{SourceEnvProfile, true},
|
||||
{SourceEnvAppID, true},
|
||||
{SourceConfigCurrentApp, false},
|
||||
{SourceConfigFirstApp, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
sel := IdentitySelection{Source: c.src}
|
||||
if sel.Explicit() != c.explicit {
|
||||
t.Errorf("source %q: Explicit()=%v want %v", c.src, sel.Explicit(), c.explicit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,24 @@ func TestFullChain_EnvWins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullChain_EnvRejectsDifferentApp(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env_app")
|
||||
t.Setenv(envvars.CliAppSecret, "env_secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "env_uat")
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&envprovider.Provider{}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT, AppID: "other_app",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want app binding error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullChain_Fallthrough(t *testing.T) {
|
||||
// env provider returns nil (no env vars set), falls through to default token
|
||||
ep := &envprovider.Provider{}
|
||||
@@ -59,7 +77,8 @@ func TestFullChain_Fallthrough(t *testing.T) {
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
nil, mock, nil,
|
||||
&mockDefaultAccountProvider{account: &credential.Account{AppID: "app1"}},
|
||||
mock, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT, AppID: "app1",
|
||||
@@ -72,6 +91,14 @@ func TestFullChain_Fallthrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type mockDefaultAccountProvider struct {
|
||||
account *credential.Account
|
||||
}
|
||||
|
||||
func (m *mockDefaultAccountProvider) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return m.account, nil
|
||||
}
|
||||
|
||||
type mockDefaultTokenProvider struct {
|
||||
token string
|
||||
scopes string
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
|
||||
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
CliProfile = "LARKSUITE_CLI_PROFILE"
|
||||
|
||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"
|
||||
|
||||
23
internal/event/testmain_test.go
Normal file
23
internal/event/testmain_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// 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)
|
||||
}
|
||||
28
internal/keychain/testmain_test.go
Normal file
28
internal/keychain/testmain_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -7,70 +7,91 @@ 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{}) {
|
||||
FormatAsCSVPaginated(w, data, true)
|
||||
// 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 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 {
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
} else {
|
||||
PrintJson(w, data)
|
||||
return WriteJSON(w, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if isFirstPage {
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isList {
|
||||
// Single object: key,value rows
|
||||
cw := csv.NewWriter(w)
|
||||
if isFirstPage {
|
||||
cw.Write([]string{"key", "value"})
|
||||
if err := cw.Write([]string{"key", "value"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, col := range cols {
|
||||
cw.Write([]string{col, rows[0][col]})
|
||||
if err := cw.Write([]string{col, rows[0][col]}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
flushCSV(cw)
|
||||
return
|
||||
return flushCSV(cw)
|
||||
}
|
||||
|
||||
writeCSVRows(w, rows, cols, isFirstPage)
|
||||
return 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) {
|
||||
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) error {
|
||||
cw := csv.NewWriter(w)
|
||||
if writeHeader {
|
||||
cw.Write(cols)
|
||||
if err := cw.Write(cols); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
record := make([]string, len(cols))
|
||||
for i, col := range cols {
|
||||
record[i] = row[col]
|
||||
}
|
||||
cw.Write(record)
|
||||
if err := cw.Write(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
flushCSV(cw)
|
||||
return flushCSV(cw)
|
||||
}
|
||||
|
||||
// flushCSV flushes the csv.Writer and reports any write error to stderr.
|
||||
func flushCSV(cw *csv.Writer) {
|
||||
// flushCSV flushes the csv.Writer and returns any write error.
|
||||
func flushCSV(cw *csv.Writer) error {
|
||||
cw.Flush()
|
||||
if err := cw.Error(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "csv write error: %v\n", err)
|
||||
}
|
||||
return cw.Error()
|
||||
}
|
||||
|
||||
@@ -50,10 +50,11 @@ 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) {
|
||||
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
|
||||
if alert == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
|
||||
_, err := fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
|
||||
alert.Provider, strings.Join(alert.MatchedRules, ", "))
|
||||
return err
|
||||
}
|
||||
|
||||
336
internal/output/emitter.go
Normal file
336
internal/output/emitter.go
Normal file
@@ -0,0 +1,336 @@
|
||||
// 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
|
||||
}
|
||||
350
internal/output/emitter_contract_test.go
Normal file
350
internal/output/emitter_contract_test.go
Normal file
@@ -0,0 +1,350 @@
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
827
internal/output/emitter_legacy_compat_test.go
Normal file
827
internal/output/emitter_legacy_compat_test.go
Normal file
@@ -0,0 +1,827 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -34,27 +34,17 @@ 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 {
|
||||
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
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,34 +101,44 @@ 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 {
|
||||
PrintNdjson(w, items)
|
||||
} else {
|
||||
PrintNdjson(w, data)
|
||||
return WriteNDJSON(w, items)
|
||||
}
|
||||
return WriteNDJSON(w, data)
|
||||
|
||||
case FormatTable:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
FormatAsTable(w, items)
|
||||
} else {
|
||||
FormatAsTable(w, data)
|
||||
return WriteTable(w, items)
|
||||
}
|
||||
return WriteTable(w, data)
|
||||
|
||||
case FormatCSV:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
FormatAsCSV(w, items)
|
||||
} else {
|
||||
FormatAsCSV(w, data)
|
||||
return WriteCSV(w, items)
|
||||
}
|
||||
return WriteCSV(w, data)
|
||||
|
||||
default: // FormatJSON
|
||||
PrintJson(w, data)
|
||||
return WriteJSON(w, data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,49 +158,63 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
|
||||
|
||||
// FormatPage formats one page of items.
|
||||
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
|
||||
switch pf.Format {
|
||||
case FormatJSON, FormatNDJSON:
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
PrintNdjson(pf.W, arr)
|
||||
} else {
|
||||
PrintNdjson(pf.W, data)
|
||||
}
|
||||
|
||||
case FormatTable:
|
||||
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
|
||||
widths := computeColumnWidths(rows, cols)
|
||||
if isFirst {
|
||||
writeHeader(w, cols, widths)
|
||||
}
|
||||
for _, row := range rows {
|
||||
writeRow(w, row, cols, widths)
|
||||
}
|
||||
})
|
||||
|
||||
case FormatCSV:
|
||||
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
|
||||
writeCSVRows(w, rows, cols, isFirst)
|
||||
})
|
||||
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)
|
||||
}
|
||||
return WriteNDJSON(pf.W, data)
|
||||
|
||||
case FormatTable:
|
||||
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
|
||||
widths := computeColumnWidths(rows, cols)
|
||||
if isFirst {
|
||||
if err := writeHeader(w, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := writeRow(w, row, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
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)) {
|
||||
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool) error) error {
|
||||
rows, pageCols, isList := prepareRows(data)
|
||||
if len(rows) == 0 {
|
||||
if pf.isFirstPage && isList {
|
||||
fmt.Fprintln(pf.W, "(empty)")
|
||||
_, err := fmt.Fprintln(pf.W, "(empty)")
|
||||
return err
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if pf.isFirstPage {
|
||||
// Lock columns from first page
|
||||
pf.cols = pageCols
|
||||
pf.isFirstPage = false
|
||||
emit(pf.W, rows, pf.cols, true)
|
||||
return emit(pf.W, rows, pf.cols, true)
|
||||
} else {
|
||||
// Reuse first page's columns — missing keys become empty, extra keys ignored
|
||||
emit(pf.W, rows, pf.cols, false)
|
||||
return emit(pf.W, rows, pf.cols, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -15,12 +16,44 @@ 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 {
|
||||
fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
|
||||
return
|
||||
return &outputMarshalError{err: err}
|
||||
}
|
||||
fmt.Fprintln(w, string(b))
|
||||
_, err = fmt.Fprintln(w, string(b))
|
||||
return err
|
||||
}
|
||||
|
||||
// injectNotice adds a "_notice" field into CLI envelope maps.
|
||||
@@ -50,21 +83,38 @@ func injectNotice(data interface{}) {
|
||||
|
||||
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
|
||||
func PrintNdjson(w io.Writer, data interface{}) {
|
||||
emit := func(item 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 {
|
||||
b, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ndjson marshal error: %v\n", err)
|
||||
return
|
||||
return &outputMarshalError{err: err}
|
||||
}
|
||||
fmt.Fprintln(w, string(b))
|
||||
_, err = fmt.Fprintln(w, string(b))
|
||||
return err
|
||||
}
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
emit(item)
|
||||
if err := emit(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emit(data)
|
||||
return nil
|
||||
}
|
||||
return emit(data)
|
||||
}
|
||||
|
||||
func cellStr(val interface{}) string {
|
||||
|
||||
@@ -16,50 +16,69 @@ const maxColWidth = 100
|
||||
// - map[string]interface{} (single object) → key-value two-column table
|
||||
// - empty array → "(empty)"
|
||||
func FormatAsTable(w io.Writer, data interface{}) {
|
||||
FormatAsTablePaginated(w, data, true)
|
||||
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 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 {
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
} else {
|
||||
// Not a list and not an object — print as JSON fallback
|
||||
PrintJson(w, data)
|
||||
return WriteJSON(w, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if isFirstPage {
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isList {
|
||||
// Single object: key-value two-column format
|
||||
formatKeyValueTable(w, rows[0], cols)
|
||||
return
|
||||
return formatKeyValueTable(w, rows[0], cols)
|
||||
}
|
||||
|
||||
// Calculate column widths (clamped to maxColWidth)
|
||||
widths := computeColumnWidths(rows, cols)
|
||||
|
||||
if isFirstPage {
|
||||
writeHeader(w, cols, widths)
|
||||
if err := writeHeader(w, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
writeRow(w, row, cols, widths)
|
||||
if err := writeRow(w, row, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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) {
|
||||
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) error {
|
||||
maxKeyWidth := 0
|
||||
for _, col := range cols {
|
||||
kw := stringWidth(col)
|
||||
@@ -71,8 +90,11 @@ func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
|
||||
for _, col := range cols {
|
||||
val := row[col]
|
||||
val = truncateToWidth(val, maxColWidth)
|
||||
fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val)
|
||||
if _, err := fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeColumnWidths returns display widths for each column, clamped to maxColWidth.
|
||||
@@ -99,25 +121,29 @@ 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) {
|
||||
func writeHeader(w io.Writer, cols []string, widths []int) error {
|
||||
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]))
|
||||
}
|
||||
fmt.Fprintln(w, strings.Join(header, " "))
|
||||
fmt.Fprintln(w, strings.Join(sep, " "))
|
||||
if _, err := fmt.Fprintln(w, strings.Join(header, " ")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(w, strings.Join(sep, " "))
|
||||
return err
|
||||
}
|
||||
|
||||
// writeRow writes a single data row.
|
||||
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) {
|
||||
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) error {
|
||||
var cells []string
|
||||
for i, col := range cols {
|
||||
val := truncateToWidth(row[col], widths[i])
|
||||
cells = append(cells, padToWidth(val, widths[i]))
|
||||
}
|
||||
fmt.Fprintln(w, strings.Join(cells, " "))
|
||||
_, err := fmt.Fprintln(w, strings.Join(cells, " "))
|
||||
return err
|
||||
}
|
||||
|
||||
// padToWidth pads a string with spaces to reach the target display width.
|
||||
|
||||
107
internal/output/testdata/runtime_context_legacy.golden.json
vendored
Normal file
107
internal/output/testdata/runtime_context_legacy.golden.json
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
internal/output/testdata/write_success_envelope_legacy.golden.json
vendored
Normal file
41
internal/output/testdata/write_success_envelope_legacy.golden.json
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,18 @@ 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")
|
||||
@@ -34,12 +40,11 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
body, err := commentBody(*eventPath)
|
||||
diags, err := auditEvent(*eventPath, *kind)
|
||||
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)))
|
||||
}
|
||||
@@ -47,32 +52,44 @@ 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) (string, error) {
|
||||
func commentBody(path string) (commentContent, error) {
|
||||
safePath, err := validate.SafeInputPath(path)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
return commentContent{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
WithParam("--event").
|
||||
WithCause(err)
|
||||
}
|
||||
data, err := vfs.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return commentContent{}, err
|
||||
}
|
||||
var payload eventPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return "", err
|
||||
return commentContent{}, err
|
||||
}
|
||||
switch {
|
||||
case payload.Comment != nil:
|
||||
return payload.Comment.Body, nil
|
||||
return commentContent{Body: payload.Comment.Body, Path: payload.Comment.Path}, nil
|
||||
case payload.Review != nil:
|
||||
return payload.Review.Body, nil
|
||||
return commentContent{Body: payload.Review.Body}, nil
|
||||
default:
|
||||
return "", nil
|
||||
return commentContent{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
)
|
||||
|
||||
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
@@ -32,11 +34,92 @@ func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got != "clean comment" {
|
||||
t.Fatalf("comment body = %q", got)
|
||||
if got.Body != "clean comment" || got.Path != "" {
|
||||
t.Fatalf("comment content = %#v", 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 {
|
||||
|
||||
@@ -6,10 +6,11 @@ package diff
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
)
|
||||
|
||||
func TestScopeIncludesChangedSkillAndRelatedDomain(t *testing.T) {
|
||||
@@ -122,8 +123,7 @@ func writeFile(t *testing.T, repo, rel, content string) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
@@ -131,8 +131,7 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
|
||||
func gitOutput(t *testing.T, repo string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v", args, err)
|
||||
|
||||
@@ -6,10 +6,11 @@ package publiccontent
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
)
|
||||
|
||||
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
@@ -23,9 +24,10 @@ 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 = "example-public-key"
|
||||
api_`+`key = "`+providerValue+`"
|
||||
`)
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
|
||||
@@ -199,13 +201,14 @@ 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":"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"}`,
|
||||
`{"access_` + `token":"` + providerValue + `"}`,
|
||||
`{"client_` + `secret": "` + providerValue + `"}`,
|
||||
`{"tenantAccess` + `Token":"` + providerValue + `"}`,
|
||||
`{"github` + `Token":"` + providerValue + `"}`,
|
||||
`{"vendorApi` + `Key":"` + providerValue + `"}`,
|
||||
`{"slackBot` + `Token":"xoxb_` + `1234567890abcdef"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add json config")
|
||||
@@ -215,14 +218,7 @@ 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{
|
||||
"real-json-token",
|
||||
"real secret value",
|
||||
"real-tenant-camel-token",
|
||||
"real-github-token",
|
||||
"real-vendor-key",
|
||||
"xoxb-real-token",
|
||||
} {
|
||||
for _, forbidden := range []string{providerValue, "xoxb_" + "1234567890abcdef"} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -306,8 +302,8 @@ func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
if count != 2 {
|
||||
t.Fatalf("angle-wrapped provider credential findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,12 +334,12 @@ func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
if count != 4 {
|
||||
t.Fatalf("provider-shaped benign-key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -358,15 +354,11 @@ func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.
|
||||
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" {
|
||||
count++
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
@@ -374,7 +366,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" + "IAIOSFODNN7EXAMPX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -391,7 +383,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
|
||||
if strings.Contains(item.Excerpt, accessKey) {
|
||||
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
@@ -432,7 +424,7 @@ func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -448,15 +440,11 @@ func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(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" {
|
||||
count++
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
|
||||
@@ -489,12 +477,13 @@ 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: 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",
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
@@ -506,13 +495,7 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -621,7 +604,8 @@ 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")
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN="+providerValue+"\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add scanner fixtures")
|
||||
|
||||
@@ -685,10 +669,11 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
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")
|
||||
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")
|
||||
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN="+providerValue+"\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add special paths")
|
||||
|
||||
@@ -855,8 +840,7 @@ 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 := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
@@ -865,8 +849,7 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
|
||||
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
package publiccontent
|
||||
|
||||
func ScanComment(kind, body string) []Finding {
|
||||
return ScanCommentAtPath(kind, "", body)
|
||||
}
|
||||
|
||||
func ScanCommentAtPath(kind, path, body string) []Finding {
|
||||
if kind == "" {
|
||||
kind = "comment"
|
||||
}
|
||||
return scanText(kind, "comment", body, false)
|
||||
if path == "" {
|
||||
path = kind
|
||||
}
|
||||
return scanText(path, "comment", body, isDetectorRuleFile(path))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package publiccontent
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
|
||||
@@ -17,3 +20,60 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
88
internal/qualitygate/publiccontent/credential.go
Normal file
88
internal/qualitygate/publiccontent/credential.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// 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')
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user