mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
59 Commits
codex/fix-
...
fix/slides
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee24a6336a | ||
|
|
6e5308af01 | ||
|
|
87be09ef5f | ||
|
|
a575a8ba60 | ||
|
|
1f565a290b | ||
|
|
68a77eee5c | ||
|
|
29a97dbde8 | ||
|
|
29a6a7b600 | ||
|
|
c167163d70 | ||
|
|
7988515e1c | ||
|
|
c7adff7a3b | ||
|
|
59237f3104 | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d | ||
|
|
781d188a60 | ||
|
|
2e0fb9a880 | ||
|
|
927b37cd63 | ||
|
|
d2e22c5fca | ||
|
|
fdae560014 | ||
|
|
1b173e1953 | ||
|
|
57db1b3a8d | ||
|
|
4c1c5f5287 | ||
|
|
3d2c10cd0b | ||
|
|
03de81c5f3 | ||
|
|
7abcaa7f68 | ||
|
|
8fb2476985 | ||
|
|
56c9a2afd8 | ||
|
|
2029189809 | ||
|
|
ee427979a8 | ||
|
|
545abcbbde | ||
|
|
4a73e83f1e | ||
|
|
7496420fa8 | ||
|
|
43fabdf524 | ||
|
|
8c46c74105 | ||
|
|
70777c86c3 | ||
|
|
38e8806d91 | ||
|
|
a7865cd0a7 | ||
|
|
f77b7eea68 | ||
|
|
dd7f741b62 | ||
|
|
e7d5ecdd01 | ||
|
|
4807283368 | ||
|
|
d2bb36591f | ||
|
|
5a54bc07db | ||
|
|
a528b3cb69 | ||
|
|
f0176af330 | ||
|
|
715aa8d960 | ||
|
|
ebc0c53ab5 | ||
|
|
1e682bd97c | ||
|
|
70424c486c | ||
|
|
b8f56dbc0b | ||
|
|
c74d9b63fb | ||
|
|
67015eef8e | ||
|
|
af8507ea8e | ||
|
|
02c2ebcf7c | ||
|
|
abf6f99d7e | ||
|
|
8ba910eb9f | ||
|
|
78bf126bb0 | ||
|
|
4eefe32c1a | ||
|
|
8f6f8eb0fc |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
|
||||
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
|
||||
|
||||
46
.github/workflows/semantic-review.yml
vendored
46
.github/workflows/semantic-review.yml
vendored
@@ -25,19 +25,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
@@ -253,19 +250,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
|
||||
119
CHANGELOG.md
119
CHANGELOG.md
@@ -2,6 +2,120 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: add +member-list shortcut (#1795)
|
||||
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||
- propagate invocation metadata (#2097)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: wait for base role update visibility (#2087)
|
||||
|
||||
### Misc
|
||||
|
||||
- Feat/detect line text overlap (#2069)
|
||||
|
||||
## [v1.0.79] - 2026-07-28
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: update xsd (#2067)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci**: validate static workflow identity (#2015)
|
||||
- **sheets**: recognize OFL0X local office tokens (#2063)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: clarify identity selection by event ownership (#2071)
|
||||
- **slides**: add formula inline element syntax to quick-ref (#2077)
|
||||
|
||||
## [v1.0.78] - 2026-07-27
|
||||
|
||||
### Features
|
||||
|
||||
- event description support rich text (#1975)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: restrict canvas overflow checks
|
||||
- **slides**: upgrade text overflow to error above 10px threshold
|
||||
- **slides**: detect letterSpacing-driven text overflow
|
||||
- **slides**: downgrade background-decoration text overflow to info
|
||||
- **slides**: allow chartParsedValues roundtrip tag
|
||||
- refine character width estimation for lark-slides text lint
|
||||
- **slides**: preserve info lint severity
|
||||
- **slides**: text may over flow shape
|
||||
- exempt ghost text from slides lint
|
||||
|
||||
## [v1.0.77] - 2026-07-24
|
||||
|
||||
### Features
|
||||
|
||||
- introducing official card icon (#1973)
|
||||
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
|
||||
- **apps**: support absolute and relative upload paths (#2005)
|
||||
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
|
||||
- **slides**: add layout density lint for sparse/empty containers (#2022)
|
||||
- add risk-control protection (#1910)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: normalize presentation flag aliases (#2032)
|
||||
- **base**: classify +form-submit as high-risk-write (#1969)
|
||||
- **slides**: declare screenshot scope
|
||||
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: clarify scope handling for query expansion (#2030)
|
||||
- **base**: clarify complete and partial updates (#1993)
|
||||
- **skills**: clarify callout child rules (#2048)
|
||||
|
||||
### Misc
|
||||
|
||||
- fix/task id handling (#2023)
|
||||
- fix/task search pagination (#2041)
|
||||
|
||||
## [v1.0.75] - 2026-07-22
|
||||
|
||||
### Features
|
||||
|
||||
- add okr single create shortcut & skill text opti (#1941)
|
||||
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: improve table shortcut behavior & guidance (#1803)
|
||||
- issue#1935 & whiteboard shortcut reformat (#1980)
|
||||
- remove legacy shortcut (#1997)
|
||||
- **e2e**: inject shared credentials by identity (#1995)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: describe html5 block xml usage (#1380)
|
||||
- clarify fetch metadata and user cites (#1981)
|
||||
- add topic move collector workflow (#1473)
|
||||
- update lark doc HTML size limit (#2001)
|
||||
- **base**: align record write schema guidance (#2000)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: declare request identities explicitly (#2004)
|
||||
|
||||
### Misc
|
||||
|
||||
- harden npm release publishing (#1918)
|
||||
|
||||
## [v1.0.74] - 2026-07-21
|
||||
|
||||
### Features
|
||||
@@ -1608,6 +1722,11 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
|
||||
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
|
||||
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
|
||||
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||
|
||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/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
|
||||
|
||||
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
|
||||
|
||||
@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
|
||||
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
||||
```
|
||||
|
||||
## +search-bot
|
||||
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-search-bot.md
|
||||
|
||||
### Avoid when
|
||||
- Looking for a person rather than a bot → use [[+search-user]]
|
||||
- Running as a bot — this shortcut is user-only
|
||||
|
||||
### Tips
|
||||
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
|
||||
|
||||
### Examples
|
||||
|
||||
**Find bots by keyword**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "会议助手" --as user
|
||||
```
|
||||
|
||||
**Search inside one chat**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
|
||||
```
|
||||
|
||||
**Find bots you've chatted with**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --has-chatted --as user
|
||||
```
|
||||
|
||||
**Search several bot keywords in one call**
|
||||
```bash
|
||||
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
|
||||
```
|
||||
|
||||
## +get-user
|
||||
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,9 +55,10 @@ 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.
|
||||
@@ -67,7 +69,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
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 +80,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 +111,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 +134,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 +148,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 +165,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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderAgentName = "X-Agent-Name"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
if v := envvars.AgentName(); v != "" {
|
||||
h.Set(HeaderAgentName, v)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// Agent headers injected via BaseSecurityHeaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(envvars.CliAgentName, agentName)
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != agentName {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, agentName)
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, " "+agentName+" ")
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
142
internal/riskcontrol/osmodel.go
Normal file
142
internal/riskcontrol/osmodel.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package deviceinfo collects the platform hardware product model and the
|
||||
// platform values used by device-related risk-control headers.
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/http/httpguts"
|
||||
)
|
||||
|
||||
// OSType is the server-side risk-control operating-system enum.
|
||||
type OSType string
|
||||
|
||||
// OS type enum values for X-Agent-Os-Type.
|
||||
const (
|
||||
OSTypeUnknown = "0"
|
||||
OSTypeWindows = "1"
|
||||
OSTypeLinux = "2"
|
||||
OSTypeMacOS = "3"
|
||||
)
|
||||
|
||||
const (
|
||||
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
|
||||
TerminalTypePC = "1"
|
||||
|
||||
// Unknown is used when the hardware product model cannot be collected.
|
||||
Unknown = "Unknown"
|
||||
|
||||
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
|
||||
// Device models are short identifiers; a larger value is treated as
|
||||
// malformed rather than truncated so the header never misrepresents it.
|
||||
deviceModelMaxBytes = 256
|
||||
)
|
||||
|
||||
// Snapshot contains the deliberately small risk-control signal set.
|
||||
// ProductModel is omitted when the platform cannot provide a safe value.
|
||||
type Snapshot struct {
|
||||
OSType OSType
|
||||
ProductModel string
|
||||
}
|
||||
|
||||
// Source supplies one immutable process-level snapshot.
|
||||
type Source interface {
|
||||
Snapshot() Snapshot
|
||||
}
|
||||
|
||||
// HostSource lazily reads host signals once, after outbound policy authorizes
|
||||
// the first request. Failed probes are cached and are not retried per request.
|
||||
type HostSource struct {
|
||||
once sync.Once
|
||||
value Snapshot
|
||||
readModel func() string
|
||||
}
|
||||
|
||||
// NewHostSource creates the production host signal source.
|
||||
func NewHostSource() *HostSource {
|
||||
return &HostSource{readModel: readDeviceModel}
|
||||
}
|
||||
|
||||
// Snapshot returns the cached host signal snapshot.
|
||||
func (s *HostSource) Snapshot() Snapshot {
|
||||
if s == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
s.once.Do(func() {
|
||||
readModel := s.readModel
|
||||
if readModel == nil {
|
||||
readModel = readDeviceModel
|
||||
}
|
||||
s.value = Snapshot{
|
||||
OSType: GetOSType(OSName()),
|
||||
ProductModel: normalizeDeviceModel(readModel()),
|
||||
}
|
||||
})
|
||||
return s.value
|
||||
}
|
||||
|
||||
// normalizeModel removes non-printable characters and returns a model only
|
||||
// when the remaining text is safe to use as an HTTP header value. Input that
|
||||
// cannot produce a valid model is rejected so Get can fall back to Unknown.
|
||||
func normalizeDeviceModel(model string) string {
|
||||
if !utf8.ValidString(model) {
|
||||
return ""
|
||||
}
|
||||
model = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r == '\r' || r == '\n' || r == '\x00':
|
||||
return -1
|
||||
case unicode.IsSpace(r):
|
||||
return ' '
|
||||
case unicode.IsPrint(r):
|
||||
return r
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}, model)
|
||||
|
||||
model = strings.Join(strings.Fields(model), " ")
|
||||
|
||||
if model == "" || len(model) > deviceModelMaxBytes {
|
||||
return ""
|
||||
}
|
||||
if !httpguts.ValidHeaderFieldValue(model) {
|
||||
return ""
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
|
||||
func GetOSType(osName string) OSType {
|
||||
switch osName {
|
||||
case "Windows":
|
||||
return OSTypeWindows
|
||||
case "Linux":
|
||||
return OSTypeLinux
|
||||
case "MacOS":
|
||||
return OSTypeMacOS
|
||||
default:
|
||||
return OSTypeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// OSName returns the platform name used by GetOSType.
|
||||
func OSName() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "MacOS"
|
||||
case "windows":
|
||||
return "Windows"
|
||||
case "linux":
|
||||
return "Linux"
|
||||
default:
|
||||
return runtime.GOOS
|
||||
}
|
||||
}
|
||||
27
internal/riskcontrol/osmodel_darwin.go
Normal file
27
internal/riskcontrol/osmodel_darwin.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build darwin
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// readDeviceModel reads the current product key first and falls back to the
|
||||
// legacy model key. Trying both keys is more robust than branching on a macOS
|
||||
// version because virtualized or restricted environments may expose only one.
|
||||
func readDeviceModel() string {
|
||||
return readDarwinDeviceModel(unix.Sysctl)
|
||||
}
|
||||
|
||||
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
|
||||
for _, key := range [...]string{"hw.product", "hw.model"} {
|
||||
model, err := readSysctl(key)
|
||||
if err == nil {
|
||||
if model = normalizeDeviceModel(model); model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
48
internal/riskcontrol/osmodel_darwin_test.go
Normal file
48
internal/riskcontrol/osmodel_darwin_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build darwin
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
|
||||
t.Run("product available", func(t *testing.T) {
|
||||
var keys []string
|
||||
got := readDarwinDeviceModel(func(key string) (string, error) {
|
||||
keys = append(keys, key)
|
||||
if key == "hw.product" {
|
||||
return "Mac16,1", nil
|
||||
}
|
||||
return "", errors.New("unexpected fallback")
|
||||
})
|
||||
if got != "Mac16,1" {
|
||||
t.Fatalf("model = %q, want %q", got, "Mac16,1")
|
||||
}
|
||||
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("sysctl keys = %v, want %v", keys, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("product unavailable", func(t *testing.T) {
|
||||
var keys []string
|
||||
got := readDarwinDeviceModel(func(key string) (string, error) {
|
||||
keys = append(keys, key)
|
||||
if key == "hw.model" {
|
||||
return "MacBookPro18,3", nil
|
||||
}
|
||||
return "", errors.New("not available")
|
||||
})
|
||||
if got != "MacBookPro18,3" {
|
||||
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
|
||||
}
|
||||
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("sysctl keys = %v, want %v", keys, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
17
internal/riskcontrol/osmodel_linux.go
Normal file
17
internal/riskcontrol/osmodel_linux.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build linux
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
|
||||
// values vary widely and can expose the host or virtualization platform when
|
||||
// the CLI runs in a container or sandbox.
|
||||
func readDeviceModel() string {
|
||||
return readLinuxDeviceModel()
|
||||
}
|
||||
|
||||
func readLinuxDeviceModel() string {
|
||||
return "linux"
|
||||
}
|
||||
20
internal/riskcontrol/osmodel_linux_test.go
Normal file
20
internal/riskcontrol/osmodel_linux_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build linux
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReadDeviceModelReturnsLinux(t *testing.T) {
|
||||
if got := readDeviceModel(); got != "linux" {
|
||||
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLinuxDeviceModel(t *testing.T) {
|
||||
if got := readLinuxDeviceModel(); got != "linux" {
|
||||
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
|
||||
}
|
||||
}
|
||||
11
internal/riskcontrol/osmodel_other.go
Normal file
11
internal/riskcontrol/osmodel_other.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !darwin && !windows && !linux
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
// readDeviceModel returns an empty model on unsupported platforms.
|
||||
func readDeviceModel() string {
|
||||
return ""
|
||||
}
|
||||
143
internal/riskcontrol/osmodel_test.go
Normal file
143
internal/riskcontrol/osmodel_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
|
||||
calls := 0
|
||||
s := &HostSource{readModel: func() string {
|
||||
calls++
|
||||
return " MacBookPro18,3\n"
|
||||
}}
|
||||
|
||||
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
|
||||
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
|
||||
}
|
||||
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
|
||||
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("read called %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostSourceCachesEmptyModel(t *testing.T) {
|
||||
calls := 0
|
||||
s := &HostSource{readModel: func() string {
|
||||
calls++
|
||||
return ""
|
||||
}}
|
||||
|
||||
if got := s.Snapshot(); got.ProductModel != "" {
|
||||
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
|
||||
}
|
||||
if got := s.Snapshot(); got.ProductModel != "" {
|
||||
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("read called %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
s := &HostSource{readModel: func() string {
|
||||
calls.Add(1)
|
||||
return "ThinkPad X1 Carbon"
|
||||
}}
|
||||
|
||||
const goroutines = 32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
snapshot := s.Snapshot()
|
||||
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
|
||||
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("read called %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDeviceModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
want string
|
||||
}{
|
||||
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
|
||||
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
|
||||
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
|
||||
{name: "rejects empty", model: " \t\r\n"},
|
||||
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
|
||||
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
|
||||
{name: "normalizes tab", model: "model\tname", want: "model name"},
|
||||
{name: "removes NUL", model: "model\x00name", want: "modelname"},
|
||||
{name: "removes control character", model: "model\x1fname", want: "modelname"},
|
||||
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
|
||||
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
|
||||
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
|
||||
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
|
||||
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := normalizeDeviceModel(tt.model); got != tt.want {
|
||||
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
|
||||
for value := 0; value <= 0x7f; value++ {
|
||||
if value >= 0x20 && value < 0x7f {
|
||||
continue
|
||||
}
|
||||
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
|
||||
model := "model" + string(rune(value)) + "name"
|
||||
want := "modelname"
|
||||
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
|
||||
want = "model name"
|
||||
}
|
||||
if got := normalizeDeviceModel(model); got != want {
|
||||
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOSType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want OSType
|
||||
}{
|
||||
{name: "Windows", want: OSTypeWindows},
|
||||
{name: "Linux", want: OSTypeLinux},
|
||||
{name: "MacOS", want: OSTypeMacOS},
|
||||
{name: "unknown", want: OSTypeUnknown},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := GetOSType(tt.name); got != tt.want {
|
||||
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
44
internal/riskcontrol/osmodel_windows.go
Normal file
44
internal/riskcontrol/osmodel_windows.go
Normal file
@@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import "golang.org/x/sys/windows/registry"
|
||||
|
||||
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
|
||||
var systemInfoRegistryPaths = [...]string{
|
||||
`HARDWARE\DESCRIPTION\System\BIOS`,
|
||||
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
|
||||
`SYSTEM\HardwareConfig\Current`,
|
||||
}
|
||||
|
||||
// readDeviceModel returns the first product name found in the Windows registry.
|
||||
func readDeviceModel() string {
|
||||
return readWindowsDeviceModel(readWindowsRegistryModel)
|
||||
}
|
||||
|
||||
func readWindowsRegistryModel(path string) (string, error) {
|
||||
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
model, _, err := key.GetStringValue("SystemProductName")
|
||||
return model, err
|
||||
}
|
||||
|
||||
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
|
||||
for _, path := range systemInfoRegistryPaths {
|
||||
model, err := readRegistryModel(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if model = normalizeDeviceModel(model); model != "" {
|
||||
return model
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
78
internal/riskcontrol/osmodel_windows_test.go
Normal file
78
internal/riskcontrol/osmodel_windows_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
//go:build windows
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadWindowsDeviceModelFallback(t *testing.T) {
|
||||
readError := errors.New("registry read failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
values map[string]string
|
||||
errors map[string]error
|
||||
want string
|
||||
wantPaths []string
|
||||
}{
|
||||
{
|
||||
name: "first path wins",
|
||||
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
|
||||
want: "Surface Laptop",
|
||||
wantPaths: []string{systemInfoRegistryPaths[0]},
|
||||
},
|
||||
{
|
||||
name: "read failure falls back",
|
||||
errors: map[string]error{
|
||||
systemInfoRegistryPaths[0]: readError,
|
||||
},
|
||||
values: map[string]string{
|
||||
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
|
||||
},
|
||||
want: "ThinkPad X1 Carbon",
|
||||
wantPaths: systemInfoRegistryPaths[:2],
|
||||
},
|
||||
{
|
||||
name: "empty normalized value falls back",
|
||||
values: map[string]string{
|
||||
systemInfoRegistryPaths[0]: " \r\n\x00",
|
||||
systemInfoRegistryPaths[1]: "Latitude 7450",
|
||||
},
|
||||
want: "Latitude 7450",
|
||||
wantPaths: systemInfoRegistryPaths[:2],
|
||||
},
|
||||
{
|
||||
name: "all paths fail",
|
||||
errors: map[string]error{
|
||||
systemInfoRegistryPaths[0]: readError,
|
||||
systemInfoRegistryPaths[1]: readError,
|
||||
systemInfoRegistryPaths[2]: readError,
|
||||
},
|
||||
wantPaths: systemInfoRegistryPaths[:],
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var paths []string
|
||||
got := readWindowsDeviceModel(func(path string) (string, error) {
|
||||
paths = append(paths, path)
|
||||
if err := tt.errors[path]; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tt.values[path], nil
|
||||
})
|
||||
if got != tt.want {
|
||||
t.Fatalf("model = %q, want %q", got, tt.want)
|
||||
}
|
||||
if !reflect.DeepEqual(paths, tt.wantPaths) {
|
||||
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
138
internal/riskcontrol/transport.go
Normal file
138
internal/riskcontrol/transport.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderProductModel = "X-Agent-Device-Type"
|
||||
HeaderOSType = "X-Agent-Os-Type"
|
||||
)
|
||||
|
||||
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
|
||||
|
||||
// Transport is the feature's final outbound boundary. It removes caller- or
|
||||
// extension-supplied signal headers first and writes trusted values only after
|
||||
// authorizing an official SDK origin and authentication state.
|
||||
type Transport struct {
|
||||
next http.RoundTripper
|
||||
source Source
|
||||
}
|
||||
|
||||
// NewTransport creates the final SDK outbound policy boundary. A nil source
|
||||
// disables collection and injection while preserving restricted-header
|
||||
// stripping for opt-out and extension-credential requests.
|
||||
func NewTransport(next http.RoundTripper, source Source) *Transport {
|
||||
if next == nil {
|
||||
next = internaltransport.Fallback()
|
||||
}
|
||||
return &Transport{
|
||||
next: next,
|
||||
source: source,
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
if req.Header == nil {
|
||||
req.Header = make(http.Header)
|
||||
}
|
||||
stripRestrictedHeaders(req.Header)
|
||||
|
||||
if t.source != nil && t.routeAllowsSignals(req) {
|
||||
snapshot := t.source.Snapshot()
|
||||
if isSupportedOSType(snapshot.OSType) {
|
||||
req.Header.Set(HeaderOSType, string(snapshot.OSType))
|
||||
}
|
||||
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
|
||||
req.Header.Set(HeaderProductModel, model)
|
||||
}
|
||||
}
|
||||
return t.next.RoundTrip(req)
|
||||
}
|
||||
|
||||
func isSupportedOSType(value OSType) bool {
|
||||
switch value {
|
||||
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stripRestrictedHeaders(header http.Header) {
|
||||
for name := range header {
|
||||
for _, restricted := range restrictedHeaders {
|
||||
if strings.EqualFold(name, restricted) {
|
||||
delete(header, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type origin struct {
|
||||
scheme string
|
||||
host string
|
||||
port string
|
||||
}
|
||||
|
||||
var officialFeishuOrigins = [...]origin{
|
||||
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
|
||||
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
|
||||
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
|
||||
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
|
||||
}
|
||||
|
||||
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
|
||||
if req == nil || req.URL == nil {
|
||||
return false
|
||||
}
|
||||
return isOfficialFeishuOrigin(originOf(req.URL))
|
||||
}
|
||||
|
||||
func originOf(value *url.URL) origin {
|
||||
if value == nil {
|
||||
return origin{}
|
||||
}
|
||||
scheme := strings.ToLower(value.Scheme)
|
||||
port := value.Port()
|
||||
if port == "" {
|
||||
switch scheme {
|
||||
case "https":
|
||||
port = "443"
|
||||
case "http":
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
|
||||
}
|
||||
|
||||
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
|
||||
endpoint, err := url.Parse(endpointURL)
|
||||
if err != nil {
|
||||
return origin{}
|
||||
}
|
||||
return originOf(endpoint)
|
||||
}
|
||||
|
||||
func isOfficialFeishuOrigin(candidate origin) bool {
|
||||
if candidate.scheme != "https" || candidate.port != "443" {
|
||||
return false
|
||||
}
|
||||
for _, official := range officialFeishuOrigins {
|
||||
if candidate == official {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
124
internal/riskcontrol/transport_test.go
Normal file
124
internal/riskcontrol/transport_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package riskcontrol
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
type countingSource struct {
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (s *countingSource) Snapshot() Snapshot {
|
||||
s.calls.Add(1)
|
||||
return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
|
||||
}
|
||||
|
||||
type staticSource Snapshot
|
||||
|
||||
func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
|
||||
|
||||
func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requestURL string
|
||||
authorization string
|
||||
wantSignals bool
|
||||
}{
|
||||
{name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
|
||||
{name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
|
||||
{name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
|
||||
{name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
|
||||
{name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
|
||||
{name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
|
||||
{name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
|
||||
{name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
|
||||
{name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
source := &countingSource{}
|
||||
var received http.Header
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
received = req.Header.Clone()
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodGet, test.requestURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", test.authorization)
|
||||
req.Header.Set(HeaderOSType, "caller-value")
|
||||
req.Header.Set(HeaderProductModel, "caller-value")
|
||||
req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
|
||||
|
||||
resp, err := NewTransport(base, source).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
gotSignals := received.Get(HeaderOSType) != ""
|
||||
if gotSignals != test.wantSignals {
|
||||
t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
|
||||
}
|
||||
wantCalls := int32(0)
|
||||
if test.wantSignals {
|
||||
wantCalls = 1
|
||||
}
|
||||
if got := source.calls.Load(); got != wantCalls {
|
||||
t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
|
||||
}
|
||||
if got := req.Header.Get(HeaderOSType); got != "caller-value" {
|
||||
t.Fatalf("caller request OS header = %q, want unchanged", got)
|
||||
}
|
||||
if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
|
||||
t.Fatalf("caller request product-model header = %q, want unchanged", got)
|
||||
}
|
||||
if !test.wantSignals {
|
||||
for name := range received {
|
||||
if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
|
||||
t.Fatalf("restricted header leaked as %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportValidatesSourceSnapshot(t *testing.T) {
|
||||
var received http.Header
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
received = req.Header.Clone()
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
resp, err := NewTransport(base, staticSource{
|
||||
OSType: OSType("unsupported"),
|
||||
ProductModel: "unsafe\nvalue",
|
||||
}).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
|
||||
t.Fatalf("no signals collected: %v", received)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,13 @@ func SafeInputPath(path string) (string, error) {
|
||||
return localfileio.SafeInputPath(path)
|
||||
}
|
||||
|
||||
// LocalInputPath validates a local input path without restricting it to the
|
||||
// current working directory. It delegates to localfileio.LocalInputPath so
|
||||
// command validation and shared local-file readers use one policy.
|
||||
func LocalInputPath(path string) (string, error) {
|
||||
return localfileio.LocalInputPath(path)
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// Delegates to localfileio.SafeEnvDirPath.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
|
||||
@@ -211,6 +211,18 @@ func TestSafeLocalFlagPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
|
||||
for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
|
||||
got, err := LocalInputPath(path)
|
||||
if err != nil || got != path {
|
||||
t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := LocalInputPath("report\n.pdf"); err == nil {
|
||||
t.Fatal("LocalInputPath() unexpectedly accepted a control character")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -22,6 +23,32 @@ func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
// LocalInputPath validates an input path in the process local filesystem
|
||||
// namespace. It intentionally does not impose cwd containment or canonicalize
|
||||
// the path: absolute paths, parent-relative paths, and symlink traversal retain
|
||||
// their normal OS semantics. Character validation remains mandatory because
|
||||
// paths are user-controlled and may appear in errors or progress output.
|
||||
func LocalInputPath(path string) (string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return "", fmt.Errorf("local input path must not be empty")
|
||||
}
|
||||
if strings.IndexFunc(path, unicode.IsControl) >= 0 {
|
||||
return "", fmt.Errorf("local input path must not contain control characters")
|
||||
}
|
||||
if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateLocalInputPlatform(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func isWindowsNonLocalNamespace(path string) bool {
|
||||
normalized := strings.ReplaceAll(path, "/", `\`)
|
||||
return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
@@ -29,7 +56,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
if _, err := SafeInputPath(value); err != nil {
|
||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
||||
return "", fmt.Errorf("%s: %w", flagName, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
8
internal/vfs/localfileio/path_local_other.go
Normal file
8
internal/vfs/localfileio/path_local_other.go
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package localfileio
|
||||
|
||||
func validateLocalInputPlatform(string) error { return nil }
|
||||
33
internal/vfs/localfileio/path_local_windows.go
Normal file
33
internal/vfs/localfileio/path_local_windows.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateLocalInputPlatform(path string) error {
|
||||
if isWindowsNonLocalNamespace(path) {
|
||||
return fmt.Errorf("local input path must not use a Windows network or device namespace")
|
||||
}
|
||||
|
||||
cleaned := filepath.Clean(path)
|
||||
volume := filepath.VolumeName(cleaned)
|
||||
remainder := strings.TrimLeft(cleaned[len(volume):], `\/`)
|
||||
for _, component := range strings.FieldsFunc(remainder, func(r rune) bool {
|
||||
return r == '\\' || r == '/'
|
||||
}) {
|
||||
if component == "." || component == ".." {
|
||||
continue
|
||||
}
|
||||
if !filepath.IsLocal(component) {
|
||||
return fmt.Errorf("local input path contains a reserved Windows path component %q", component)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
27
internal/vfs/localfileio/path_local_windows_test.go
Normal file
27
internal/vfs/localfileio/path_local_windows_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package localfileio
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLocalInputPath_RejectsWindowsNetworkDeviceAndReservedPaths(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`\\server\share\report.pdf`,
|
||||
`//server/share/report.pdf`,
|
||||
`\\.\pipe\upload`,
|
||||
`\\?\C:\Users\agent\report.pdf`,
|
||||
`\\?\UNC\server\share\report.pdf`,
|
||||
`\??\C:\Users\agent\report.pdf`,
|
||||
`C:\Users\agent\NUL.txt`,
|
||||
`CON`,
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
if _, err := LocalInputPath(input); err == nil {
|
||||
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -71,6 +72,72 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"/tmp/report.pdf",
|
||||
"../outside/report.pdf",
|
||||
"./report.pdf",
|
||||
"nested/../report.pdf",
|
||||
`C:\Users\agent\report.pdf`,
|
||||
"报告.pdf",
|
||||
} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
got, err := LocalInputPath(input)
|
||||
if err != nil {
|
||||
t.Fatalf("LocalInputPath(%q) error = %v", input, err)
|
||||
}
|
||||
if got != input {
|
||||
t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsNonLocalNamespace(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`\\server\share\report.pdf`,
|
||||
`//server/share/report.pdf`,
|
||||
`\\.\pipe\upload`,
|
||||
`\\?\C:\Users\agent\report.pdf`,
|
||||
`\\?\UNC\server\share\report.pdf`,
|
||||
`\??\C:\Users\agent\report.pdf`,
|
||||
} {
|
||||
if !isWindowsNonLocalNamespace(input) {
|
||||
t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
|
||||
}
|
||||
}
|
||||
|
||||
for _, input := range []string{
|
||||
`C:\Users\agent\report.pdf`,
|
||||
`C:/Users/agent/report.pdf`,
|
||||
`..\outside\report.pdf`,
|
||||
`.\report.pdf`,
|
||||
} {
|
||||
if isWindowsNonLocalNamespace(input) {
|
||||
t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"",
|
||||
" ",
|
||||
"file\x00.txt",
|
||||
"file\tname.txt",
|
||||
"file\nname.txt",
|
||||
"file\rname.txt",
|
||||
"file\u202Ename.txt",
|
||||
"file\u200Bname.txt",
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
|
||||
if _, err := LocalInputPath(input); err == nil {
|
||||
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a clean temp directory as CWD
|
||||
dir := t.TempDir()
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.80",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.80",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
"arm64",
|
||||
"riscv64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.74",
|
||||
"version": "1.0.80",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install.js"
|
||||
"postinstall": "node scripts/install.js",
|
||||
"release:check": "node scripts/release-preflight.js"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
|
||||
@@ -265,10 +265,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
const checksumsPath = path.join(dir, "checksums.txt");
|
||||
|
||||
if (!fs.existsSync(checksumsPath)) {
|
||||
console.error(
|
||||
"[WARN] checksums.txt not found, skipping checksum verification"
|
||||
);
|
||||
return null;
|
||||
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(checksumsPath, "utf8");
|
||||
@@ -286,7 +283,14 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
}
|
||||
|
||||
function verifyChecksum(archivePath, expectedHash) {
|
||||
if (expectedHash === null) return;
|
||||
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
|
||||
throw new Error("[SECURITY] Expected checksum is missing or invalid");
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
|
||||
throw new Error(
|
||||
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
|
||||
);
|
||||
}
|
||||
|
||||
// Stream the file to avoid loading the entire archive into memory.
|
||||
// Archives can be 10-100MB; streaming keeps RSS constant.
|
||||
|
||||
@@ -52,11 +52,12 @@ describe("getExpectedChecksum", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when checksums.txt does not exist", () => {
|
||||
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||
// No checksums.txt in dir
|
||||
const result = getExpectedChecksum("anything.tar.gz", dir);
|
||||
assert.equal(result, null);
|
||||
assert.throws(
|
||||
() => getExpectedChecksum("anything.tar.gz", dir),
|
||||
{ message: /^\[SECURITY\] checksums\.txt not found/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("skips malformed lines and still finds valid entry", () => {
|
||||
@@ -106,7 +107,7 @@ describe("verifyChecksum", () => {
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
it("accepts a valid uppercase 64-character hex hash", () => {
|
||||
const content = "case test";
|
||||
const filePath = makeTmpFile(content);
|
||||
const hash = sha256(content).toUpperCase();
|
||||
@@ -114,6 +115,40 @@ describe("verifyChecksum", () => {
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
for (const [name, expectedHash] of [
|
||||
["null", null],
|
||||
["empty", ""],
|
||||
["non-string", 123],
|
||||
]) {
|
||||
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, expectedHash),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
assert.match(err.message, /Expected checksum is missing or invalid/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "abc123"),
|
||||
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY] format Error for a non-hex hash", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "g".repeat(64)),
|
||||
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY]-prefixed Error on mismatch", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
|
||||
108
scripts/release-preflight.js
Normal file
108
scripts/release-preflight.js
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
|
||||
|
||||
function isStableVersion(value) {
|
||||
return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
|
||||
}
|
||||
|
||||
function releaseError(message, observed, hint) {
|
||||
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
|
||||
}
|
||||
|
||||
function validateReleasePreflight(packageJson, packageLockJson, tag) {
|
||||
const packageVersion = packageJson?.version;
|
||||
const lockVersion = packageLockJson?.version;
|
||||
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
|
||||
const observed = {
|
||||
packageVersion: packageVersion ?? null,
|
||||
lockVersion: lockVersion ?? null,
|
||||
lockRootVersion: lockRootVersion ?? null,
|
||||
tagVersion: null,
|
||||
};
|
||||
|
||||
for (const [field, value] of [
|
||||
["package.json.version", packageVersion],
|
||||
["package-lock.json.version", lockVersion],
|
||||
['package-lock.json.packages[""].version', lockRootVersion],
|
||||
]) {
|
||||
if (!isStableVersion(value)) {
|
||||
return releaseError(
|
||||
`${field} must be a stable release version in X.Y.Z form`,
|
||||
observed,
|
||||
"Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
|
||||
return releaseError(
|
||||
"Package version fields do not match",
|
||||
observed,
|
||||
"Synchronize package.json.version and both package-lock.json version fields.",
|
||||
);
|
||||
}
|
||||
|
||||
if (tag === undefined) {
|
||||
return { ok: true, data: observed };
|
||||
}
|
||||
if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
|
||||
return releaseError(
|
||||
"--tag must use the stable release form vX.Y.Z",
|
||||
{ ...observed, tag },
|
||||
`Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
|
||||
);
|
||||
}
|
||||
|
||||
const tagVersion = tag.slice(1);
|
||||
if (tagVersion !== packageVersion) {
|
||||
return releaseError(
|
||||
"Tag version does not match the package version",
|
||||
{ ...observed, tagVersion, tag },
|
||||
`Use --tag v${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
return { ok: true, data: { ...observed, tagVersion } };
|
||||
}
|
||||
|
||||
function writeResult(result) {
|
||||
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let tag;
|
||||
if (args.length === 2 && args[0] === "--tag") {
|
||||
tag = args[1];
|
||||
} else if (args.length !== 0) {
|
||||
writeResult(releaseError(
|
||||
"Expected no arguments or --tag vX.Y.Z",
|
||||
{ arguments: args },
|
||||
"Run release:check without arguments or pass exactly one --tag value.",
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
||||
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
|
||||
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
|
||||
} catch (error) {
|
||||
writeResult(releaseError(
|
||||
"Could not read release package metadata",
|
||||
{ reason: error.message },
|
||||
"Ensure package.json and package-lock.json exist and contain valid JSON.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateReleasePreflight };
|
||||
|
||||
if (require.main === module) main();
|
||||
66
scripts/release-preflight.test.js
Normal file
66
scripts/release-preflight.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { describe, it } = require("node:test");
|
||||
|
||||
const { validateReleasePreflight } = require("./release-preflight");
|
||||
|
||||
function metadata(version = "1.2.3") {
|
||||
return {
|
||||
packageJson: { version },
|
||||
packageLockJson: {
|
||||
version,
|
||||
packages: { "": { version } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertRejected(result) {
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.type, "release_preflight");
|
||||
assert.equal(typeof result.error.message, "string");
|
||||
}
|
||||
|
||||
describe("validateReleasePreflight", () => {
|
||||
it("accepts matching stable package, lock, and tag versions", () => {
|
||||
const { packageJson, packageLockJson } = metadata();
|
||||
|
||||
assert.deepEqual(
|
||||
validateReleasePreflight(packageJson, packageLockJson, "v1.2.3"),
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
packageVersion: "1.2.3",
|
||||
lockVersion: "1.2.3",
|
||||
lockRootVersion: "1.2.3",
|
||||
tagVersion: "1.2.3",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-stable or inconsistent package metadata", () => {
|
||||
const prerelease = metadata("1.2.3-beta.1");
|
||||
const topLevelMismatch = metadata();
|
||||
topLevelMismatch.packageLockJson.version = "1.2.4";
|
||||
const rootMismatch = metadata();
|
||||
rootMismatch.packageLockJson.packages[""].version = "1.2.4";
|
||||
|
||||
for (const { packageJson, packageLockJson } of [
|
||||
prerelease,
|
||||
topLevelMismatch,
|
||||
rootMismatch,
|
||||
]) {
|
||||
assertRejected(validateReleasePreflight(packageJson, packageLockJson));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an invalid or mismatched release tag", () => {
|
||||
const { packageJson, packageLockJson } = metadata();
|
||||
|
||||
for (const tag of ["1.2.3", "v1.2.3-beta.1", "v1.2.4"]) {
|
||||
assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
||||
if grep -Fq 'run.name !== "CI"' "$workflow"; then
|
||||
echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
|
||||
require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
|
||||
require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
|
||||
require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
|
||||
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
|
||||
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
|
||||
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
|
||||
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
|
||||
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
|
||||
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
|
||||
|
||||
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
|
||||
require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
|
||||
require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
|
||||
require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
|
||||
require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
|
||||
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
|
||||
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
|
||||
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
|
||||
|
||||
@@ -3,49 +3,48 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
# Read version from package.json
|
||||
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: could not read version from package.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
TAG="v${VERSION}"
|
||||
|
||||
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
|
||||
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Tag: ${TAG}"
|
||||
|
||||
# Check if tag already exists locally
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag ${TAG} already exists locally, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if tag already exists on remote
|
||||
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
|
||||
echo "Tag ${TAG} already exists on remote, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure package.json changes are committed before tagging
|
||||
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
|
||||
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
if [ "${CURRENT_BRANCH}" != "main" ]; then
|
||||
echo "Error: releases must be tagged from main; current branch is '${CURRENT_BRANCH}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure current branch is pushed to remote before tagging
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
LOCAL_SHA=$(git rev-parse HEAD)
|
||||
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
|
||||
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
|
||||
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
|
||||
if ! git diff --quiet HEAD -- package.json package-lock.json; then
|
||||
echo "Error: package.json or package-lock.json has uncommitted changes. Please commit them before tagging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create and push tag
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
git fetch origin main
|
||||
|
||||
echo "Successfully created and pushed tag ${TAG}"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
FETCHED_MAIN_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
|
||||
if [ "${HEAD_SHA}" != "${FETCHED_MAIN_SHA}" ]; then
|
||||
echo "Error: HEAD must exactly match origin/main before tagging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||
echo "Error: local tag ${TAG} already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
|
||||
if [ -n "${REMOTE_TAG}" ]; then
|
||||
echo "Error: remote tag ${TAG} already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git tag "${TAG}" "${HEAD_SHA}"
|
||||
git push origin "refs/tags/${TAG}"
|
||||
|
||||
echo "Successfully pushed tag ${TAG}"
|
||||
|
||||
@@ -12,10 +12,23 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
|
||||
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
|
||||
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
|
||||
const maxFileListPageSize = 200
|
||||
|
||||
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
|
||||
func validateFileListPageSize(n int) error {
|
||||
if n < 1 || n > maxFileListPageSize {
|
||||
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
|
||||
//
|
||||
// GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt /
|
||||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size/--page-token。
|
||||
// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size(1..200)/--page-token。
|
||||
// file 域不分 dev/online,无 --env。
|
||||
//
|
||||
// pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。
|
||||
@@ -41,13 +54,17 @@ var AppsFileList = common.Shortcut{
|
||||
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
|
||||
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
|
||||
return err
|
||||
}
|
||||
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。
|
||||
for _, f := range []string{"uploaded-since", "uploaded-until"} {
|
||||
if strings.TrimSpace(rctx.Str(f)) == "" {
|
||||
|
||||
@@ -82,6 +82,34 @@ func TestAppsFileList_RequiresAppID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileList_PageSizeOutOfRange 验证 --page-size 超出 (0, 200] 契约时前置报 --page-size 校验错误,不发请求。
|
||||
func TestAppsFileList_PageSizeOutOfRange(t *testing.T) {
|
||||
for _, ps := range []string{"0", "201", "500"} {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("page-size=%s: err = %T %v, want *errs.ValidationError", ps, err, err)
|
||||
}
|
||||
if ve.Param != "--page-size" {
|
||||
t.Fatalf("page-size=%s: Param = %q, want --page-size", ps, ve.Param)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileList_PageSizeBoundaryOK 验证边界值 1 与 200 通过校验(dry-run 不报错并把 page_size 下发)。
|
||||
func TestAppsFileList_PageSizeBoundaryOK(t *testing.T) {
|
||||
for _, ps := range []string{"1", "200"} {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("page-size=%s: dry-run err=%v", ps, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_since/until 原样)。
|
||||
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -47,21 +46,7 @@ var AppsFileUpload = common.Shortcut{
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
f := strings.TrimSpace(rctx.Str("file"))
|
||||
if f == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
|
||||
}
|
||||
st, err := rctx.FileIO().Stat(f)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
|
||||
}
|
||||
if st.Size() > fileUploadMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
|
||||
}
|
||||
return nil
|
||||
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
@@ -76,9 +61,9 @@ var AppsFileUpload = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
localPath := strings.TrimSpace(rctx.Str("file"))
|
||||
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
|
||||
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
return err
|
||||
}
|
||||
fileName := filepath.Base(localPath)
|
||||
contentType := mimeByExt(fileName)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -58,22 +59,17 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_upload,body.file_name 取文件 basename。
|
||||
// TestAppsFileUpload_DryRunPreUpload verifies that dry-run validates the local
|
||||
// file and previews the pre-upload request without reading or uploading it.
|
||||
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
// Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
|
||||
absolutePath := filepath.Join(t.TempDir(), "logo.png")
|
||||
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env dryRunAPIEnvelope
|
||||
@@ -87,6 +83,18 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
|
||||
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 三步直传:pre-upload → 客户端 PUT 字节 → callback。
|
||||
func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
var putBody []byte
|
||||
@@ -149,6 +157,142 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
|
||||
// absolute path outside the current working directory.
|
||||
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
|
||||
var putBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("ETag", `"etag-abs"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Keep the process cwd unchanged so the temporary file is outside it.
|
||||
dir := t.TempDir()
|
||||
absFile := filepath.Join(dir, "report.pdf")
|
||||
if !filepath.IsAbs(absFile) {
|
||||
t.Fatalf("test setup: %q is not absolute", absFile)
|
||||
}
|
||||
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute with absolute path err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PDFBYTES" {
|
||||
t.Fatalf("PUT body = %q, want file bytes", putBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
|
||||
var putBody []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("ETag", `"etag-parent"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
root := t.TempDir()
|
||||
workDir := filepath.Join(root, "work")
|
||||
if err := os.Mkdir(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(workDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute with parent-relative path err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PARENT" {
|
||||
t.Fatalf("PUT body = %q, want PARENT", putBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "too-large.bin")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
|
||||
_ = f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err = runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "limit") {
|
||||
t.Fatalf("error = %v, want size limit context", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("/dev/zero is unavailable on Windows")
|
||||
}
|
||||
if _, err := os.Stat("/dev/zero"); err != nil {
|
||||
t.Skipf("/dev/zero unavailable: %v", err)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Error(), "regular file") {
|
||||
t.Fatalf("error = %v, want non-regular-file context", validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName:空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
|
||||
func TestSanitizeUploadFileName_Cases(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
|
||||
@@ -2435,16 +2435,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1", "rec_2"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"create_records":[{"Name":"Alice"},{"Name":"Bob"}]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -250,7 +251,8 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
"total": 2,
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "发票抬头", "required": false, "description": nil,
|
||||
"visible_rule": map[string]interface{}{"logic": "and", "conditions": []interface{}{[]interface{}{"q_001", "==", "是"}}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -258,9 +260,14 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// The list output must forward visible_rule verbatim so agents can read existing display conditions.
|
||||
if !strings.Contains(got, `"visible_rule"`) {
|
||||
t.Fatalf("visible_rule missing from list output: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
@@ -296,11 +303,49 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
t.Fatalf("expected error for invalid questions JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visible_rule passthrough", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_new1", "title": "发票抬头"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
rule, ok := body.Questions[0]["visible_rule"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
if rule["logic"] != "and" {
|
||||
t.Fatalf("visible_rule logic not preserved: %#v", rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
@@ -311,15 +356,29 @@ func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`}
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true,"visible_rule":{"logic":"and","conditions":[["q_002","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// visible_rule must be forwarded verbatim to the API (transcribe faithfully).
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
if _, ok := body.Questions[0]["visible_rule"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {
|
||||
|
||||
@@ -25,14 +25,21 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -25,14 +25,26 @@ var BaseFormQuestionsUpdate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Update uses full question overwrite semantics: omitted/empty fields are written as defaults/empty, so run +form-questions-list first and include existing values you want to keep. Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id; pass null or omit to clear). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Update uses full question overwrite semantics, not a patch.",
|
||||
"Run +form-questions-list first and include existing title/description/required/option_display_mode/visible_rule values you want to keep.",
|
||||
"Omitted fields reset to defaults; empty strings, null, and empty arrays are written as empty/clear when accepted by the API.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+form-submit",
|
||||
Description: "Submit a form (fill and submit form data)",
|
||||
Risk: "write",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"base:form:update", "docs:document.media:upload"},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
@@ -39,6 +39,7 @@ var BaseFormSubmit = common.Shortcut{
|
||||
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
|
||||
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
|
||||
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
|
||||
baseHighRiskYesTip,
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateFormSubmit(runtime)
|
||||
|
||||
@@ -783,6 +783,20 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question create visible_rule",
|
||||
shortcut: BaseFormQuestionsCreate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question update visible_rule",
|
||||
shortcut: BaseFormQuestionsUpdate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search json",
|
||||
shortcut: BaseRecordSearch,
|
||||
@@ -801,7 +815,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
name: "record batch create json",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
wantHelp: []string{
|
||||
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
|
||||
"create_records contains one field map per record",
|
||||
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -850,8 +865,8 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
`{"Parent Link":[{"id":"rec_xxx"}]}`,
|
||||
"do not look for parent_record_id or a separate child-record API",
|
||||
"CellValue happy path: text/phone/url",
|
||||
"select -> \"Todo\"",
|
||||
"multi-select -> [\"Tag A\",\"Tag B\"]",
|
||||
"select (multiple=false) -> \"Todo\"",
|
||||
"select (multiple=true) -> [\"Tag A\",\"Tag B\"]",
|
||||
"datetime -> \"2026-03-24 10:00:00\"",
|
||||
"checkbox -> true/false",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
@@ -865,11 +880,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
name: "record batch create",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
wantTips: []string{
|
||||
"Happy path fields: fields is the column order",
|
||||
"rows is an array of row arrays",
|
||||
"may use null for empty cells",
|
||||
"Happy path field: create_records",
|
||||
"create_records is an array of independent record field maps",
|
||||
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
|
||||
"use +field-list to confirm real writable fields",
|
||||
"Batch create supports max 200 rows per call",
|
||||
"Batch create supports max 200 records per call",
|
||||
"do not immediately +record-list the same table",
|
||||
"CellValue happy path: text/phone/url",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
@@ -1027,6 +1042,39 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsUpdateHelpGuidesFullOverwrite(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFormQuestionsUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
wantHelp := []string{
|
||||
"Update uses full question overwrite semantics",
|
||||
"run +form-questions-list first",
|
||||
"include existing values you want to keep",
|
||||
"pass null or omit to clear",
|
||||
}
|
||||
for _, want := range wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
"full question overwrite semantics, not a patch",
|
||||
"Run +form-questions-list first",
|
||||
"title/description/required/option_display_mode/visible_rule",
|
||||
"Omitted fields reset to defaults",
|
||||
"empty strings, null, and empty arrays are written as empty/clear",
|
||||
}
|
||||
for _, want := range wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -2055,8 +2103,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
|
||||
if s.Service != "base" {
|
||||
t.Fatalf("Service=%q want base", s.Service)
|
||||
}
|
||||
if s.Risk != "write" {
|
||||
t.Fatalf("Risk=%q want write", s.Risk)
|
||||
if s.Risk != "high-risk-write" {
|
||||
t.Fatalf("Risk=%q want high-risk-write", s.Risk)
|
||||
}
|
||||
if !s.HasFormat {
|
||||
t.Fatal("HasFormat should be true")
|
||||
@@ -2356,6 +2404,7 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
"+form-submit",
|
||||
"--share-token", "shr_exec1",
|
||||
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
|
||||
"--yes",
|
||||
}
|
||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -2424,6 +2473,7 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
"--share-token", "shr_exec6",
|
||||
"--base-token", "bas_exec6",
|
||||
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||
if err == nil {
|
||||
@@ -2472,6 +2522,7 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
"--share-token", "shr_dedup",
|
||||
"--base-token", "bas_dedup",
|
||||
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -2483,6 +2534,33 @@ func TestExecuteFormSubmit(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
|
||||
// without --yes the runner's confirmation gate must fire before Execute runs,
|
||||
// returning a typed confirmation_required error and touching no API.
|
||||
func TestFormSubmitRequiresConfirmation(t *testing.T) {
|
||||
if BaseFormSubmit.Risk != "high-risk-write" {
|
||||
t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
|
||||
}
|
||||
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := []string{
|
||||
"+form-submit",
|
||||
"--share-token", "shr_confirm",
|
||||
"--json", `{"fields":{"Rating":5}}`,
|
||||
}
|
||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation_required error without --yes")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeConfirmationRequired {
|
||||
t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentsParallel(t *testing.T) {
|
||||
t.Run("single file upload via execute path", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
@@ -2519,6 +2597,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
|
||||
"--share-token", "shr_para1",
|
||||
"--base-token", "bas_para1",
|
||||
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
@@ -2553,6 +2632,7 @@ func TestUploadAttachmentsParallel(t *testing.T) {
|
||||
"--share-token", "shr_err",
|
||||
"--base-token", "bas_err",
|
||||
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
|
||||
"--yes",
|
||||
}
|
||||
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
|
||||
if err == nil {
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseFieldSearchOptions = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
|
||||
"Use only for fields with options, such as select or multi-select fields.",
|
||||
"Use only for select fields, whether multiple is false or true.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
|
||||
@@ -19,12 +19,13 @@ var BaseRecordBatchCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
|
||||
{Name: "json", Desc: `batch create JSON object; create_records contains one field map per record, e.g. {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`, Required: true},
|
||||
},
|
||||
Tips: append([]string{
|
||||
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
|
||||
"Happy path field: create_records is an array of independent record field maps.",
|
||||
`Example: {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}.`,
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Batch create supports max 200 rows per call.",
|
||||
"Batch create supports max 200 records per call.",
|
||||
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
|
||||
"Use the record-batch-create guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
|
||||
@@ -19,7 +19,7 @@ const maxBatchGetSelectFieldCount = 100
|
||||
const maxRecordSearchSelectFieldCount = 50
|
||||
|
||||
var recordCellValueHappyPathTips = []string{
|
||||
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
|
||||
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select (multiple=false) -> "Todo"; select (multiple=true) -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
|
||||
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
|
||||
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",
|
||||
|
||||
@@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
collapseDescription(e)
|
||||
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
|
||||
eventData := map[string]interface{}{
|
||||
"summary": runtime.Str("summary"),
|
||||
"description": runtime.Str("description"),
|
||||
"start_time": map[string]string{"timestamp": startTs},
|
||||
"end_time": map[string]string{"timestamp": endTs},
|
||||
"attendee_ability": "can_modify_event",
|
||||
@@ -33,6 +32,9 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
|
||||
if rrule := runtime.Str("rrule"); rrule != "" {
|
||||
eventData["recurrence"] = rrule
|
||||
}
|
||||
if description := descriptionToSend(runtime); description != "" {
|
||||
eventData["description_rich"] = description
|
||||
}
|
||||
return eventData
|
||||
}
|
||||
|
||||
@@ -118,7 +120,7 @@ var CalendarCreate = common.Shortcut{
|
||||
{Name: "summary", Desc: "event title"},
|
||||
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
|
||||
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
|
||||
{Name: "description", Desc: "event description"},
|
||||
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `<br>**bold**`).", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||
@@ -231,6 +233,9 @@ var CalendarCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
|
||||
}
|
||||
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
eventData := buildEventData(runtime, startTs, endTs)
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ type calendarEvent struct {
|
||||
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DescriptionRich string `json:"description_rich,omitempty"`
|
||||
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||
VChat *calendarEventVChat `json:"vchat,omitempty"`
|
||||
@@ -169,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
|
||||
if status, _ := out["status"].(string); status != "cancelled" {
|
||||
delete(out, "status")
|
||||
}
|
||||
|
||||
collapseDescription(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -988,9 +988,15 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured patch body: %v", err)
|
||||
}
|
||||
if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" {
|
||||
// --description is the unified field, treated as rich text and sent as
|
||||
// description_rich; the CLI never sends the plain description field
|
||||
// (mutually exclusive downstream).
|
||||
if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
|
||||
t.Fatalf("unexpected patch body: %#v", body)
|
||||
}
|
||||
if _, ok := body["description"]; ok {
|
||||
t.Fatalf("plain description must not be sent, got: %#v", body)
|
||||
}
|
||||
if body["need_notification"] != false {
|
||||
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
|
||||
}
|
||||
@@ -1364,6 +1370,62 @@ func TestAgenda_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/events/instance_view",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"event_id": "evt_rich",
|
||||
"summary": "Rich",
|
||||
"status": "confirmed",
|
||||
"description": "[测试]\n友情提醒",
|
||||
"description_rich": "友情提醒",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"event_id": "evt_plain",
|
||||
"summary": "Plain",
|
||||
"status": "confirmed",
|
||||
"description": "just text",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, CalendarAgenda, []string{
|
||||
"+agenda",
|
||||
"--start", "2025-03-21",
|
||||
"--end", "2025-03-21",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
// Read exposes a single unified description field: it carries the rich
|
||||
// (Markdown) value when present, and the plain text otherwise. The internal
|
||||
// description_rich key is never surfaced.
|
||||
if !strings.Contains(out, "\"description\": \"友情提醒\"") {
|
||||
t.Errorf("expected rich value surfaced under description, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "\"description\": \"just text\"") {
|
||||
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgenda_EmptyResult(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
@@ -3375,6 +3437,72 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_UnifiesDescriptionRich(t *testing.T) {
|
||||
// Read exposes a single unified description field carrying the rich value
|
||||
// when present, and the plain text otherwise; description_rich is dropped.
|
||||
t.Run("rich present", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_rich",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_rich",
|
||||
"summary": "Rich",
|
||||
"description": "[表格]",
|
||||
"description_rich": "| a | b |\n| --- | --- |\n| c | d |",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_rich", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "| a | b |") {
|
||||
t.Errorf("expected rich value surfaced under description, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
// When only a plain description exists, it is surfaced under description.
|
||||
t.Run("only plain surfaces under description", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_plain",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_plain",
|
||||
"summary": "Plain",
|
||||
"description": "just text",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_plain", "--as", "bot"}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "\"description\": \"just text\"") {
|
||||
t.Errorf("expected plain description surfaced, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "description_rich") {
|
||||
t.Errorf("description_rich must not appear in output, got: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ var CalendarUpdate = common.Shortcut{
|
||||
{Name: "event-id", Desc: "event ID to update", Required: true},
|
||||
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||
{Name: "summary", Desc: "event title"},
|
||||
{Name: "description", Desc: "event description"},
|
||||
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (``; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `<br>**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
|
||||
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
|
||||
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
|
||||
@@ -109,11 +109,13 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
|
||||
body := map[string]interface{}{}
|
||||
hasFields := false
|
||||
|
||||
for _, field := range []string{"summary", "description"} {
|
||||
if runtime.Cmd.Flags().Changed(field) {
|
||||
body[field] = runtime.Str(field)
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("summary") {
|
||||
body["summary"] = runtime.Str("summary")
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
body["description_rich"] = runtime.Str("description")
|
||||
hasFields = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("rrule") {
|
||||
rrule := strings.TrimSpace(runtime.Str("rrule"))
|
||||
@@ -356,6 +358,12 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
|
||||
}
|
||||
|
||||
if runtime.Cmd.Flags().Changed("description") {
|
||||
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -428,8 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
|
||||
if summary, _ := event["summary"].(string); summary != "" {
|
||||
result["summary"] = summary
|
||||
}
|
||||
if description, _ := event["description"].(string); description != "" {
|
||||
result["description"] = description
|
||||
if rich, _ := event["description_rich"].(string); rich != "" {
|
||||
result["description"] = rich
|
||||
} else if plain, _ := event["description"].(string); plain != "" {
|
||||
result["description"] = plain
|
||||
}
|
||||
if start := formatCalendarEventTime(event["start_time"]); start != "" {
|
||||
result["start"] = start
|
||||
|
||||
172
shortcuts/calendar/description_rich_images.go
Normal file
172
shortcuts/calendar/description_rich_images.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
// Register the common image decoders so DecodeConfig can read intrinsic
|
||||
// dimensions for PNG/JPEG/GIF sources.
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const calendarMediaParentType = "calendar"
|
||||
|
||||
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
|
||||
|
||||
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
|
||||
md := runtime.Str("description")
|
||||
if md == "" || !strings.Contains(md, "![") {
|
||||
return nil
|
||||
}
|
||||
rewritten, changed, err := uploadLocalDescriptionImages(runtime, calendarID, md)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
if err := runtime.Cmd.Flags().Set("description", rewritten); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description after image upload: %v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadLocalDescriptionImages(runtime *common.RuntimeContext, calendarID, md string) (string, bool, error) {
|
||||
matches := markdownImageRe.FindAllStringSubmatchIndex(md, -1)
|
||||
if len(matches) == 0 {
|
||||
return md, false, nil
|
||||
}
|
||||
var out strings.Builder
|
||||
last := 0
|
||||
changed := false
|
||||
cache := map[string]string{}
|
||||
for _, m := range matches {
|
||||
altStart, altEnd, srcStart, srcEnd := m[2], m[3], m[4], m[5]
|
||||
src := strings.TrimSpace(md[srcStart:srcEnd])
|
||||
if !isLocalImageSrc(src) {
|
||||
continue
|
||||
}
|
||||
alt := md[altStart:altEnd]
|
||||
uploadedURL, err := resolveLocalImage(runtime, calendarID, src, alt, cache)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
out.WriteString(md[last:srcStart])
|
||||
out.WriteString(uploadedURL)
|
||||
last = srcEnd
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return md, false, nil
|
||||
}
|
||||
out.WriteString(md[last:])
|
||||
return out.String(), true, nil
|
||||
}
|
||||
|
||||
func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt string, cache map[string]string) (string, error) {
|
||||
localPath := localImagePath(src)
|
||||
if cached, ok := cache[localPath]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
safePath, err := validate.SafeInputPath(localPath)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--description image %q could not be read: %v", src, err).
|
||||
WithParam("--description").
|
||||
WithHint("reference local images by a path inside the current working directory (e.g. ./images/pic.png; cd there first), or use an already-uploaded Lark image URL").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
info, err := runtime.FileIO().Stat(localPath)
|
||||
if err != nil {
|
||||
return "", common.WrapInputStatErrorTyped(err)
|
||||
}
|
||||
|
||||
fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: localPath,
|
||||
FileName: filepath.Base(safePath),
|
||||
FileSize: info.Size(),
|
||||
ParentType: calendarMediaParentType,
|
||||
ParentNode: &calendarID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
width, height := decodeImageDimensions(runtime, localPath)
|
||||
uploadedURL := buildCalendarImagePreviewURL(runtime.Config.Brand, fileToken, width, height, info.Size())
|
||||
cache[localPath] = uploadedURL
|
||||
return uploadedURL, nil
|
||||
}
|
||||
|
||||
func decodeImageDimensions(runtime *common.RuntimeContext, path string) (int, int) {
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
func isLocalImageSrc(src string) bool {
|
||||
if src == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(src)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "data:"):
|
||||
return false
|
||||
case strings.HasPrefix(lower, "file://"):
|
||||
return true
|
||||
}
|
||||
if i := strings.Index(src, "://"); i > 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func localImagePath(src string) string {
|
||||
s := strings.TrimSpace(src)
|
||||
if strings.HasPrefix(strings.ToLower(s), "file://") {
|
||||
if u, err := url.Parse(s); err == nil && u.Path != "" {
|
||||
s = u.Path
|
||||
}
|
||||
}
|
||||
if decoded, err := url.PathUnescape(s); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
|
||||
host := "internal-api-drive-stream.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "internal-api-drive-stream.larksuite.com"
|
||||
}
|
||||
u := fmt.Sprintf("https://%s/space/api/box/stream/download/preview/%s?preview_type=16", host, fileToken)
|
||||
if width > 0 && height > 0 {
|
||||
u += fmt.Sprintf("&im_w=%d&im_h=%d", width, height)
|
||||
}
|
||||
if size > 0 {
|
||||
u += fmt.Sprintf("&im_size=%d", size)
|
||||
}
|
||||
return u
|
||||
}
|
||||
279
shortcuts/calendar/description_rich_images_test.go
Normal file
279
shortcuts/calendar/description_rich_images_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"image"
|
||||
"image/png"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestIsLocalImageSrc(t *testing.T) {
|
||||
cases := []struct {
|
||||
src string
|
||||
want bool
|
||||
}{
|
||||
{"./images/pic.png", true},
|
||||
{"images/pic.png", true},
|
||||
{"../assets/a.png", true},
|
||||
{"/Users/me/Desktop/a.png", true},
|
||||
{`C:\Users\me\a.png`, true},
|
||||
{"file:///Users/me/a.png", true},
|
||||
{"图片和附件/测试图片.png", true},
|
||||
{"https://example.com/a.png", false},
|
||||
{"http://example.com/a.png", false},
|
||||
{"HTTPS://EXAMPLE.com/a.png", false},
|
||||
{"data:image/png;base64,iVBOR", false},
|
||||
{"ftp://host/a.png", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isLocalImageSrc(c.src); got != c.want {
|
||||
t.Errorf("isLocalImageSrc(%q) = %v, want %v", c.src, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalImagePath(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"images/pic.png", "images/pic.png"},
|
||||
{"images/my%20pic.png", "images/my pic.png"},
|
||||
{"file:///Users/me/a.png", "/Users/me/a.png"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := localImagePath(c.in); got != c.want {
|
||||
t.Errorf("localImagePath(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCalendarImagePreviewURL guards the contract the OpenAPI service
|
||||
// relies on: a Lark host (so token extraction triggers) whose final path
|
||||
// segment is exactly the uploaded file token.
|
||||
func TestBuildCalendarImagePreviewURL(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
brand core.LarkBrand
|
||||
hostFrag string
|
||||
}{
|
||||
{core.BrandFeishu, "feishu.cn"},
|
||||
{core.BrandLark, "larksuite"},
|
||||
} {
|
||||
raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("built URL not parseable: %v", err)
|
||||
}
|
||||
if !strings.Contains(u.Host, tc.hostFrag) {
|
||||
t.Errorf("brand %s host = %q, want fragment %q", tc.brand, u.Host, tc.hostFrag)
|
||||
}
|
||||
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if last := segs[len(segs)-1]; last != "boxcnTOKEN123" {
|
||||
t.Errorf("last path segment = %q, want token", last)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("im_w") != "416" || q.Get("im_h") != "306" || q.Get("im_size") != "142568" {
|
||||
t.Errorf("dimension params missing: im_w=%q im_h=%q im_size=%q", q.Get("im_w"), q.Get("im_h"), q.Get("im_size"))
|
||||
}
|
||||
}
|
||||
|
||||
// With unknown dimensions the helper params are omitted entirely.
|
||||
raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0)
|
||||
if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") {
|
||||
t.Errorf("expected no dimension params for unknown size, got %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadLocalDescriptionImages_RemoteUntouched verifies remote/data images
|
||||
// pass through unchanged and never trigger an upload (runtime unused → nil).
|
||||
func TestUploadLocalDescriptionImages_RemoteUntouched(t *testing.T) {
|
||||
md := "text  more "
|
||||
got, changed, err := uploadLocalDescriptionImages(nil, "cal", md)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Errorf("changed = true, want false")
|
||||
}
|
||||
if got != md {
|
||||
t.Errorf("markdown mutated: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_UploadsLocalDescriptionImage runs +create with a local image path,
|
||||
// mocks the drive upload, and asserts the create body's description_rich carries
|
||||
// the uploaded token (not the local path).
|
||||
func TestCreate_UploadsLocalDescriptionImage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chdir(orig)
|
||||
if err := os.WriteFile(filepath.Join(dir, "pic.png"), []byte("PNGDATA"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_001",
|
||||
"summary": "Pic",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
runErr := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Pic",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr != nil {
|
||||
t.Fatalf("unexpected error: %v", runErr)
|
||||
}
|
||||
|
||||
if uploadStub.CapturedBody == nil {
|
||||
t.Fatalf("expected drive upload to be called")
|
||||
}
|
||||
if createStub.CapturedBody == nil {
|
||||
t.Fatalf("expected create event to be called")
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("create body unmarshal: %v", err)
|
||||
}
|
||||
dr, _ := body["description_rich"].(string)
|
||||
if !strings.Contains(dr, "boxcnTOKEN123") {
|
||||
t.Fatalf("description_rich should contain uploaded token, got %q", dr)
|
||||
}
|
||||
if strings.Contains(dr, "./pic.png") {
|
||||
t.Fatalf("local path should be rewritten away, got %q", dr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_LocalImageCarriesDimensions verifies a real decodable image's
|
||||
// intrinsic width/height and byte size are appended to the rewritten drive URL
|
||||
// (so the facade can populate originalWidth/originalHeight and the client can
|
||||
// render the image inline).
|
||||
func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chdir(orig)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 5, 7))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "pic.png"), buf.Bytes(), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
|
||||
})
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_001",
|
||||
"summary": "Pic",
|
||||
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
runErr := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Pic",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr != nil {
|
||||
t.Fatalf("unexpected error: %v", runErr)
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("create body unmarshal: %v", err)
|
||||
}
|
||||
dr, _ := body["description_rich"].(string)
|
||||
if !strings.Contains(dr, "im_w=5") || !strings.Contains(dr, "im_h=7") {
|
||||
t.Fatalf("description_rich should carry image dimensions, got %q", dr)
|
||||
}
|
||||
if !strings.Contains(dr, "im_size=") {
|
||||
t.Fatalf("description_rich should carry image byte size, got %q", dr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
|
||||
// yields a typed --description validation error before any API call.
|
||||
func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
runErr := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Pic",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--description", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if runErr == nil {
|
||||
t.Fatalf("expected error for absolute image path")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(runErr, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
|
||||
}
|
||||
if ve.Param != "--description" {
|
||||
t.Errorf("param = %q, want --description", ve.Param)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,26 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
|
||||
return startInput, endInput
|
||||
}
|
||||
|
||||
func collapseDescription(event map[string]interface{}) {
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
rich, _ := event["description_rich"].(string)
|
||||
plain, _ := event["description"].(string)
|
||||
delete(event, "description_rich")
|
||||
switch {
|
||||
case rich != "":
|
||||
event["description"] = rich
|
||||
case plain != "":
|
||||
event["description"] = plain
|
||||
default:
|
||||
delete(event, "description")
|
||||
}
|
||||
}
|
||||
func descriptionToSend(runtime *common.RuntimeContext) string {
|
||||
return runtime.Str("description")
|
||||
}
|
||||
|
||||
func hasExplicitBotFlag(cmd *cobra.Command) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
|
||||
146
shortcuts/common/localfile.go
Normal file
146
shortcuts/common/localfile.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// ValidateLocalFileFlag validates that a local input path exists, is a regular
|
||||
// file, and does not exceed maxBytes. Absolute and relative paths use
|
||||
// the process filesystem namespace.
|
||||
func (ctx *RuntimeContext) ValidateLocalFileFlag(flagName string, maxBytes int64) error {
|
||||
path, param, err := ctx.localFileFlag(flagName, maxBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := cmdutil.StatLocalFile(path)
|
||||
if err != nil {
|
||||
return localFileReadError(param, path, "inspect", err)
|
||||
}
|
||||
if err := localFileRegularError(param, path, info.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxBytes {
|
||||
return localFileSizeError(param, path, info.Size(), maxBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadLocalFileFlag is the shared replacement for direct os.ReadFile calls in
|
||||
// shortcuts. It accepts absolute and relative paths, enforces a hard size
|
||||
// limit, and returns command-facing typed errors.
|
||||
func (ctx *RuntimeContext) ReadLocalFileFlag(flagName string, maxBytes int64) (data []byte, retErr error) {
|
||||
path, param, err := ctx.localFileFlag(flagName, maxBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := cmdutil.OpenLocalFile(path)
|
||||
if err != nil {
|
||||
return nil, localFileReadError(param, path, "open", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil && retErr == nil {
|
||||
data = nil
|
||||
retErr = errs.NewInternalError(errs.SubtypeFileIO, "cannot close %s %q: %v", param, path, err).WithCause(err)
|
||||
}
|
||||
}()
|
||||
|
||||
openedInfo, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, localFileReadError(param, path, "inspect opened", err)
|
||||
}
|
||||
if err := localFileRegularError(param, path, openedInfo.Mode()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if openedInfo.Size() > maxBytes {
|
||||
return nil, localFileSizeError(param, path, openedInfo.Size(), maxBytes)
|
||||
}
|
||||
|
||||
readLimit := maxBytes + 1
|
||||
if maxBytes == math.MaxInt64 {
|
||||
readLimit = maxBytes
|
||||
}
|
||||
data, err = io.ReadAll(io.LimitReader(f, readLimit))
|
||||
if err != nil {
|
||||
return nil, localFileReadError(param, path, "read", err)
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q grew beyond the %d-byte limit while being read", param, path, maxBytes).
|
||||
WithParam(param)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) localFileFlag(flagName string, maxBytes int64) (path, param string, err error) {
|
||||
name, param, err := localFileFlagNames(flagName)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if ctx == nil || ctx.Cmd == nil {
|
||||
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "cannot read %s: runtime command is unavailable", param)
|
||||
}
|
||||
|
||||
path = strings.TrimSpace(ctx.Str(name))
|
||||
if path == "" {
|
||||
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s is required", param).WithParam(param)
|
||||
}
|
||||
if _, err := validate.LocalInputPath(path); err != nil {
|
||||
return "", param, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s path: %v", param, err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
}
|
||||
if maxBytes < 0 {
|
||||
return "", param, errs.NewInternalError(errs.SubtypeUnknown, "invalid read limit configured for %s", param)
|
||||
}
|
||||
return path, param, nil
|
||||
}
|
||||
|
||||
func localFileRegularError(param, path string, mode fs.FileMode) error {
|
||||
if mode.IsRegular() {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q is not a regular file", param, path).
|
||||
WithParam(param)
|
||||
}
|
||||
|
||||
func localFileReadError(param, path, op string, cause error) error {
|
||||
if errors.Is(cause, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s %q: %v", param, path, cause).
|
||||
WithParam(param).
|
||||
WithCause(cause)
|
||||
}
|
||||
if errors.Is(cause, fs.ErrNotExist) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s %q does not exist", param, path).
|
||||
WithParam(param).
|
||||
WithCause(cause)
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "cannot %s %s %q: %v", op, param, path, cause).WithCause(cause)
|
||||
}
|
||||
|
||||
func localFileSizeError(param, path string, size, limit int64) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q is %d bytes; limit is %d bytes", param, path, size, limit).
|
||||
WithParam(param)
|
||||
}
|
||||
|
||||
func localFileFlagNames(flagName string) (name, param string, err error) {
|
||||
name = strings.TrimLeft(strings.TrimSpace(flagName), "-")
|
||||
if name == "" {
|
||||
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "local file flag name must not be empty")
|
||||
}
|
||||
return name, "--" + name, nil
|
||||
}
|
||||
95
shortcuts/common/localfile_test.go
Normal file
95
shortcuts/common/localfile_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestReadLocalFileFlag_AcceptsAbsolutePath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "input.txt")
|
||||
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rctx := localFileTestRuntime(t, path)
|
||||
|
||||
if err := rctx.ValidateLocalFileFlag("file", 7); err != nil {
|
||||
t.Fatalf("ValidateLocalFileFlag() error = %v", err)
|
||||
}
|
||||
got, err := rctx.ReadLocalFileFlag("file", 7)
|
||||
if err != nil || string(got) != "content" {
|
||||
t.Fatalf("ReadLocalFileFlag() = %q, %v; want content", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path func(t *testing.T) string
|
||||
max int64
|
||||
}{
|
||||
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
|
||||
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
|
||||
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
|
||||
{name: "too large", path: func(t *testing.T) string {
|
||||
path := filepath.Join(t.TempDir(), "large")
|
||||
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}, max: 5},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := localFileTestRuntime(t, tc.path(t)).ValidateLocalFileFlag("file", tc.max)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLocalFileFlag_ReturnsTypedInputErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path func(t *testing.T) string
|
||||
max int64
|
||||
}{
|
||||
{name: "invalid characters", path: func(*testing.T) string { return "input\n.txt" }, max: 10},
|
||||
{name: "missing file", path: func(t *testing.T) string { return filepath.Join(t.TempDir(), "missing") }, max: 10},
|
||||
{name: "directory", path: func(t *testing.T) string { return t.TempDir() }, max: 10},
|
||||
{name: "too large", path: func(t *testing.T) string {
|
||||
path := filepath.Join(t.TempDir(), "large")
|
||||
if err := os.WriteFile(path, []byte("123456"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}, max: 5},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := localFileTestRuntime(t, tc.path(t)).ReadLocalFileFlag("file", tc.max)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
|
||||
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func localFileTestRuntime(t *testing.T, path string) *RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
if err := cmd.Flags().Set("file", path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &RuntimeContext{ctx: context.Background(), Cmd: cmd}
|
||||
}
|
||||
447
shortcuts/contact/contact_search_bot.go
Normal file
447
shortcuts/contact/contact_search_bot.go
Normal file
@@ -0,0 +1,447 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const botSearchURL = "/open-apis/bot/v4/bot/search"
|
||||
|
||||
const (
|
||||
maxBotSearchQueryChars = 50
|
||||
maxBotSearchChatIDs = 100
|
||||
maxBotSearchPageSize = 30
|
||||
)
|
||||
|
||||
type botSearchAPIRequest struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
Filter *botSearchAPIFilter `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
// HasChatter uses omitempty: validation rejects =false, so a set field is always
|
||||
// true and an unset field stays out of the request entirely.
|
||||
type botSearchAPIFilter struct {
|
||||
ChatIDs []string `json:"chat_ids,omitempty"`
|
||||
HasChatter bool `json:"has_chatter,omitempty"`
|
||||
}
|
||||
|
||||
type botSearchAPIData struct {
|
||||
Items []botSearchAPIItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
Notice string `json:"notice"`
|
||||
}
|
||||
|
||||
type botSearchAPIItem struct {
|
||||
ID string `json:"id"`
|
||||
DisplayInfo string `json:"display_info"`
|
||||
MetaData botSearchAPIMeta `json:"meta_data"`
|
||||
}
|
||||
|
||||
type botSearchAPIMeta struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
EnableJoinGroup bool `json:"enable_join_group"`
|
||||
ChatID string `json:"chat_id"`
|
||||
IsAgent bool `json:"is_agent"`
|
||||
}
|
||||
|
||||
type searchBot struct {
|
||||
OpenID string `json:"open_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// ChatID is the caller's P2P chat with the bot.
|
||||
ChatID string `json:"chat_id"`
|
||||
EnableJoinGroup bool `json:"enable_join_group"`
|
||||
IsAgent bool `json:"is_agent"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
MatchSegments []string `json:"match_segments"`
|
||||
}
|
||||
|
||||
// PageToken is decoded from the response but deliberately not surfaced, matching
|
||||
// searchUserResponse: neither search command paginates. Callers narrow the query
|
||||
// instead, so handing out a token that no flag accepts would only mislead.
|
||||
type searchBotResponse struct {
|
||||
Bots []searchBot `json:"bots"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
var ContactSearchBot = common.Shortcut{
|
||||
Service: "contact",
|
||||
Command: "+search-bot",
|
||||
Description: "Search bots (apps) by keyword — across the tenant, or inside specific chats (requires --as user)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:bot"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (≤ 50 characters); required unless --queries is given"},
|
||||
{Name: "chat-ids", Desc: "search within specific chats (CSV of chat_id; ≤ 100)"},
|
||||
{Name: "has-chatted", Type: "bool", Desc: "narrow a keyword search to bots you've chatted with (omit to disable; =false rejected)"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "rows per request, 1-30"},
|
||||
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat bots[] with matched_query plus a queries[] sidecar"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateBotSearch(runtime)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if raw := strings.TrimSpace(runtime.Str("queries")); raw != "" {
|
||||
filter, err := buildBotSearchFilter(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
api := common.NewDryRunAPI()
|
||||
for _, q := range parseAndDedupQueries(raw) {
|
||||
body := &botSearchAPIRequest{Query: q, Filter: filter}
|
||||
api.POST(botSearchURL).
|
||||
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
|
||||
Body(body)
|
||||
}
|
||||
return api
|
||||
}
|
||||
body, err := buildBotSearchBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(botSearchURL).
|
||||
Params(map[string]interface{}{"page_size": runtime.Int("page-size")}).
|
||||
Body(body)
|
||||
},
|
||||
Execute: executeBotSearch,
|
||||
}
|
||||
|
||||
// executeBotSearch dispatches to single-query or fanout mode.
|
||||
func executeBotSearch(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return executeBotSearchFanout(ctx, runtime)
|
||||
}
|
||||
return executeBotSearchSingle(ctx, runtime)
|
||||
}
|
||||
|
||||
// botSearchKeywordRequiredError names every flag that can satisfy the keyword
|
||||
// requirement. Naming only --query would tell an agent that --queries is not a
|
||||
// way out, which it is.
|
||||
func botSearchKeywordRequiredError() error {
|
||||
return common.ValidationErrorf("specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--query", Reason: "required unless --queries is given"},
|
||||
errs.InvalidParam{Name: "--queries", Reason: "required unless --query is given"},
|
||||
)
|
||||
}
|
||||
|
||||
// botSearchHasChattedFalseError is raised from two places — with and without a
|
||||
// keyword — so the wording stays in one spot.
|
||||
//
|
||||
// Agents passing =false almost always mean "do not filter", but the API reads it
|
||||
// as "must NOT match". A hard error prevents silent wrong results.
|
||||
func botSearchHasChattedFalseError() error {
|
||||
return common.ValidationErrorf("--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)").
|
||||
WithParam("--has-chatted")
|
||||
}
|
||||
|
||||
func validateBotSearch(runtime *common.RuntimeContext) error {
|
||||
queriesRaw := strings.TrimSpace(runtime.Str("queries"))
|
||||
query := strings.TrimSpace(runtime.Str("query"))
|
||||
explicitFalseHasChatted := runtime.Cmd.Flags().Changed("has-chatted") && !runtime.Bool("has-chatted")
|
||||
|
||||
if queriesRaw != "" {
|
||||
if query != "" {
|
||||
return common.ValidationErrorf("--query and --queries are mutually exclusive").
|
||||
WithParams(
|
||||
errs.InvalidParam{Name: "--query", Reason: "mutually exclusive with --queries"},
|
||||
errs.InvalidParam{Name: "--queries", Reason: "mutually exclusive with --query"},
|
||||
)
|
||||
}
|
||||
queries := parseAndDedupQueries(queriesRaw)
|
||||
if len(queries) == 0 {
|
||||
return common.ValidationErrorf("--queries: no valid query parsed from %q (separate entries with ',')", queriesRaw).
|
||||
WithParam("--queries")
|
||||
}
|
||||
if len(queries) > maxFanoutQueries {
|
||||
return common.ValidationErrorf("--queries: must be at most %d entries (got %d)", maxFanoutQueries, len(queries)).
|
||||
WithParam("--queries")
|
||||
}
|
||||
for _, q := range queries {
|
||||
if utf8.RuneCountInString(q) > maxBotSearchQueryChars {
|
||||
return common.ValidationErrorf("--queries: entry %q exceeds %d characters", q, maxBotSearchQueryChars).
|
||||
WithParam("--queries")
|
||||
}
|
||||
}
|
||||
} else if query == "" {
|
||||
// No keyword at all. An explicit =false is the more specific mistake, so
|
||||
// report it instead of sending the caller off to add a keyword only to hit
|
||||
// this on the next attempt. +search-user lands here too: a Changed bool
|
||||
// counts as search input for its "at least one" gate, so the =false check
|
||||
// is what it reaches next.
|
||||
//
|
||||
// Scoped to the no-keyword case on purpose. Hoisting it above the keyword
|
||||
// checks would let it mask the mutual-exclusion and length errors, which
|
||||
// +search-user reports first when a keyword is present.
|
||||
if explicitFalseHasChatted {
|
||||
return botSearchHasChattedFalseError()
|
||||
}
|
||||
return botSearchKeywordRequiredError()
|
||||
} else if utf8.RuneCountInString(query) > maxBotSearchQueryChars {
|
||||
return common.ValidationErrorf("--query: length must be between 1 and %d characters", maxBotSearchQueryChars).
|
||||
WithParam("--query")
|
||||
}
|
||||
|
||||
if _, err := parseBotSearchChatIDs(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if explicitFalseHasChatted {
|
||||
return botSearchHasChattedFalseError()
|
||||
}
|
||||
|
||||
if n := runtime.Int("page-size"); n < 1 || n > maxBotSearchPageSize {
|
||||
return common.ValidationErrorf("--page-size: must be between 1 and %d", maxBotSearchPageSize).
|
||||
WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBotSearchChatIDs(runtime *common.RuntimeContext) ([]string, error) {
|
||||
raw := strings.TrimSpace(runtime.Str("chat-ids"))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := common.SplitCSV(raw)
|
||||
if len(parts) == 0 {
|
||||
return nil, common.ValidationErrorf("--chat-ids: no valid chat_id parsed from %q (separate entries with ',')", raw).
|
||||
WithParam("--chat-ids")
|
||||
}
|
||||
|
||||
// Normalize before deduping, then check the cap against the deduped list —
|
||||
// the same order common.resolveOpenIDs uses for --user-ids. Doing it the other
|
||||
// way would spend the server's 100-entry budget on duplicates, and would let
|
||||
// 101 copies of one chat be rejected here while the sibling command accepts
|
||||
// them. Normalization matters too: a chat URL and a bare chat_id can name the
|
||||
// same chat.
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
chatIDs := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
normalized, err := common.ValidateChatIDTyped("--chat-ids", part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, dup := seen[normalized]; dup {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
chatIDs = append(chatIDs, normalized)
|
||||
}
|
||||
if len(chatIDs) > maxBotSearchChatIDs {
|
||||
return nil, common.ValidationErrorf("--chat-ids: must be at most %d entries", maxBotSearchChatIDs).
|
||||
WithParam("--chat-ids")
|
||||
}
|
||||
return chatIDs, nil
|
||||
}
|
||||
|
||||
// buildBotSearchFilter reads the scope flags shared by single and fanout search.
|
||||
// A nil filter means "no scope": an empty filter object is not the same request.
|
||||
func buildBotSearchFilter(runtime *common.RuntimeContext) (*botSearchAPIFilter, error) {
|
||||
filter := &botSearchAPIFilter{}
|
||||
hasFilter := false
|
||||
|
||||
chatIDs, err := parseBotSearchChatIDs(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(chatIDs) > 0 {
|
||||
filter.ChatIDs = chatIDs
|
||||
hasFilter = true
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("has-chatted") && runtime.Bool("has-chatted") {
|
||||
filter.HasChatter = true
|
||||
hasFilter = true
|
||||
}
|
||||
|
||||
if !hasFilter {
|
||||
return nil, nil
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func buildBotSearchBody(runtime *common.RuntimeContext) (*botSearchAPIRequest, error) {
|
||||
filter, err := buildBotSearchFilter(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &botSearchAPIRequest{
|
||||
Query: strings.TrimSpace(runtime.Str("query")),
|
||||
Filter: filter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// botSearchStdoutCarriesEnvelope reports whether the chosen format puts the
|
||||
// response envelope — notice, has_more, and in fanout mode queries[] — into
|
||||
// stdout. Only json does; pretty, table, csv and ndjson render rows only, so
|
||||
// every piece of "this result is not the whole answer" metadata would vanish and
|
||||
// the caller would read a truncated result as a complete one. For those formats
|
||||
// the metadata goes to stderr, which keeps stdout pipe-clean. A --jq expression
|
||||
// can still project it away, but that is the caller's explicit choice.
|
||||
func botSearchStdoutCarriesEnvelope(format string) bool {
|
||||
return format == "json" || format == ""
|
||||
}
|
||||
|
||||
func executeBotSearchSingle(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildBotSearchBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: botSearchURL,
|
||||
Body: body,
|
||||
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respData, err := decodeBotSearchAPIData(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bots := projectBots(respData)
|
||||
out := searchBotResponse{
|
||||
Bots: bots,
|
||||
HasMore: respData.HasMore,
|
||||
Notice: respData.Notice,
|
||||
}
|
||||
runtime.OutFormat(out, &output.Meta{Count: len(bots)}, func(w io.Writer) {
|
||||
if len(bots) == 0 {
|
||||
fmt.Fprintln(w, "No bots found.")
|
||||
return
|
||||
}
|
||||
output.PrintTable(w, prettyBotRows(bots))
|
||||
})
|
||||
if respData.Notice != "" && !botSearchStdoutCarriesEnvelope(runtime.Format) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\nnotice: %s\n", respData.Notice)
|
||||
}
|
||||
if respData.HasMore && !botSearchStdoutCarriesEnvelope(runtime.Format) {
|
||||
fmt.Fprintln(runtime.IO().ErrOut,
|
||||
"\nhint: more matches exist; narrow with --has-chatted or a more specific --query")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeBotSearchAPIData(data map[string]interface{}) (*botSearchAPIData, error) {
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, contactInvalidResponseError("marshal bot search response data failed").WithCause(err)
|
||||
}
|
||||
var out botSearchAPIData
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, contactInvalidResponseError("decode bot search response data failed").WithCause(err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func projectBots(data *botSearchAPIData) []searchBot {
|
||||
if data == nil {
|
||||
return []searchBot{}
|
||||
}
|
||||
bots := make([]searchBot, 0, len(data.Items))
|
||||
for i := range data.Items {
|
||||
item := &data.Items[i]
|
||||
name, description, segments := parseBotDisplayInfo(item.DisplayInfo)
|
||||
bots = append(bots, searchBot{
|
||||
OpenID: item.ID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
ChatID: item.MetaData.ChatID,
|
||||
EnableJoinGroup: item.MetaData.EnableJoinGroup,
|
||||
IsAgent: item.MetaData.IsAgent,
|
||||
TenantID: item.MetaData.TenantID,
|
||||
MatchSegments: segments,
|
||||
})
|
||||
}
|
||||
return bots
|
||||
}
|
||||
|
||||
func stripHighlightTags(value string) string {
|
||||
value = strings.ReplaceAll(value, "<h>", "")
|
||||
return strings.ReplaceAll(value, "</h>", "")
|
||||
}
|
||||
|
||||
func parseBotDisplayInfo(raw string) (name, description string, matchSegments []string) {
|
||||
matchSegments = make([]string, 0)
|
||||
for _, match := range displayInfoHighlightRE.FindAllStringSubmatch(raw, -1) {
|
||||
// The capture can still carry a tag: the non-greedy pattern pairs a
|
||||
// stray `<h>` with the next `</h>`. Strip it so a segment reads like the
|
||||
// name and description it came from, and drop a highlight with no text.
|
||||
segment := html.UnescapeString(stripHighlightTags(match[1]))
|
||||
if strings.TrimSpace(segment) == "" {
|
||||
continue
|
||||
}
|
||||
matchSegments = append(matchSegments, segment)
|
||||
}
|
||||
|
||||
lines := strings.Split(raw, "\n")
|
||||
stripTags := func(value string) string {
|
||||
return strings.TrimSpace(html.UnescapeString(stripHighlightTags(value)))
|
||||
}
|
||||
|
||||
// nameLine records which line the name came from, so the description is read
|
||||
// from the line after it. Reading lines[1] unconditionally echoes the name
|
||||
// back as its own description whenever line 0 is blank, and drops the real
|
||||
// description with it.
|
||||
nameLine := -1
|
||||
if len(lines) > 0 {
|
||||
if candidate := stripTags(lines[0]); candidate != "" {
|
||||
name = candidate
|
||||
nameLine = 0
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
for i, line := range lines {
|
||||
if candidate := stripTags(line); candidate != "" {
|
||||
name = candidate
|
||||
nameLine = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if nameLine >= 0 && nameLine+1 < len(lines) {
|
||||
description = stripTags(lines[nameLine+1])
|
||||
}
|
||||
return name, description, matchSegments
|
||||
}
|
||||
|
||||
// map[] shape is required by output.PrintTable.
|
||||
func prettyBotRows(bots []searchBot) []map[string]interface{} {
|
||||
rows := make([]map[string]interface{}, 0, len(bots))
|
||||
for _, bot := range bots {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"name": bot.Name,
|
||||
"description": common.TruncateStr(bot.Description, 50),
|
||||
"is_agent": bot.IsAgent,
|
||||
"enable_join_group": bot.EnableJoinGroup,
|
||||
"open_id": bot.OpenID,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
289
shortcuts/contact/contact_search_bot_fanout.go
Normal file
289
shortcuts/contact/contact_search_bot_fanout.go
Normal file
@@ -0,0 +1,289 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// Bot fanout reuses the user fanout's query parsing, concurrency limit and
|
||||
// response summary types.
|
||||
|
||||
type botFanoutResult struct {
|
||||
Index int
|
||||
Query string
|
||||
Bots []searchBot
|
||||
HasMore bool
|
||||
Notice string
|
||||
ErrMsg string // empty = success
|
||||
Err error // original failure, kept for typed propagation
|
||||
}
|
||||
|
||||
// runOneBotQuery converts one fanout request into either bots or an error summary.
|
||||
func runOneBotQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
|
||||
filter *botSearchAPIFilter) botFanoutResult {
|
||||
// Pre-check ctx so queued workers see cancellation before issuing a request;
|
||||
// in-flight workers continue until DoAPI returns.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return botFanoutErrorResult(index, query, err)
|
||||
}
|
||||
|
||||
body := &botSearchAPIRequest{Query: query}
|
||||
if filter != nil {
|
||||
body.Filter = filter
|
||||
}
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: botSearchURL,
|
||||
Body: body,
|
||||
QueryParams: larkcore.QueryParams{"page_size": []string{strconv.Itoa(runtime.Int("page-size"))}},
|
||||
})
|
||||
if err != nil {
|
||||
return botFanoutErrorResult(index, query, err)
|
||||
}
|
||||
|
||||
data, err := runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return botFanoutErrorResult(index, query, err)
|
||||
}
|
||||
respData, err := decodeBotSearchAPIData(data)
|
||||
if err != nil {
|
||||
return botFanoutErrorResult(index, query, err)
|
||||
}
|
||||
|
||||
return botFanoutResult{
|
||||
Index: index,
|
||||
Query: query,
|
||||
Bots: projectBots(respData),
|
||||
HasMore: respData.HasMore,
|
||||
Notice: respData.Notice,
|
||||
}
|
||||
}
|
||||
|
||||
// botFanoutErrorResult records a failed fanout query without stopping other workers.
|
||||
func botFanoutErrorResult(index int, query string, err error) botFanoutResult {
|
||||
if err == nil {
|
||||
return botFanoutResult{Index: index, Query: query}
|
||||
}
|
||||
return botFanoutResult{Index: index, Query: query, ErrMsg: contactFanoutErrorSummary(err), Err: err}
|
||||
}
|
||||
|
||||
func botFanoutContextError(err error) error {
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
message := "bot search fanout cancelled"
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
message = "bot search fanout deadline exceeded"
|
||||
}
|
||||
return errs.NewNetworkError(subtype, "%s", message).WithCause(err)
|
||||
}
|
||||
|
||||
func botFanoutPanicError(query string, recovered any) error {
|
||||
err := errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"bot search query %q panicked: %v", query, recovered)
|
||||
if cause, ok := recovered.(error); ok {
|
||||
return err.WithCause(cause)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Terminal failures invalidate the batch; API and network failures remain
|
||||
// eligible for partial-success reporting.
|
||||
func botFanoutTerminalError(results []botFanoutResult) error {
|
||||
for _, result := range results {
|
||||
if result.Err == nil {
|
||||
continue
|
||||
}
|
||||
if errors.Is(result.Err, context.Canceled) || errors.Is(result.Err, context.DeadlineExceeded) {
|
||||
return botFanoutContextError(result.Err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(result.Err)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"bot search query %q failed with an unclassified error: %v", result.Query, result.Err).
|
||||
WithCause(result.Err)
|
||||
}
|
||||
if problem.Category != errs.CategoryAPI && problem.Category != errs.CategoryNetwork {
|
||||
return result.Err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fanoutBot struct {
|
||||
searchBot
|
||||
MatchedQuery string `json:"matched_query"`
|
||||
}
|
||||
|
||||
type botFanoutResponse struct {
|
||||
Bots []fanoutBot `json:"bots"`
|
||||
Queries []querySummary `json:"queries"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
// buildBotFanoutResponse flattens recoverable results in query order. Terminal
|
||||
// errors fail the batch even when another query succeeded.
|
||||
func buildBotFanoutResponse(queries []string, results []botFanoutResult) (*botFanoutResponse, error) {
|
||||
if err := botFanoutTerminalError(results); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
indexed := make([]botFanoutResult, len(queries))
|
||||
for _, r := range results {
|
||||
indexed[r.Index] = r
|
||||
}
|
||||
|
||||
out := &botFanoutResponse{
|
||||
Bots: make([]fanoutBot, 0),
|
||||
Queries: make([]querySummary, 0, len(queries)),
|
||||
}
|
||||
failed := 0
|
||||
var firstErrMsg, firstErrQuery string
|
||||
var firstErr error
|
||||
for i, r := range indexed {
|
||||
out.Queries = append(out.Queries, querySummary{
|
||||
Query: queries[i],
|
||||
Error: r.ErrMsg,
|
||||
HasMore: r.HasMore,
|
||||
Notice: r.Notice,
|
||||
})
|
||||
if r.ErrMsg != "" {
|
||||
failed++
|
||||
if firstErrMsg == "" {
|
||||
firstErrMsg = r.ErrMsg
|
||||
firstErrQuery = queries[i]
|
||||
firstErr = r.Err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if out.Notice == "" {
|
||||
out.Notice = r.Notice
|
||||
}
|
||||
for _, b := range r.Bots {
|
||||
out.Bots = append(out.Bots, fanoutBot{searchBot: b, MatchedQuery: queries[i]})
|
||||
}
|
||||
}
|
||||
if failed == len(queries) && len(queries) > 0 {
|
||||
msg := fmt.Sprintf("all %d queries failed; first: %s (query=%q)",
|
||||
len(queries), firstErrMsg, firstErrQuery)
|
||||
return nil, contactFanoutAllFailedError(firstErr, msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func executeBotSearchFanout(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
queries := parseAndDedupQueries(runtime.Str("queries"))
|
||||
|
||||
filter, err := buildBotSearchFilter(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := make([]botFanoutResult, len(queries))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, fanoutConcurrency)
|
||||
|
||||
schedule:
|
||||
for i, q := range queries {
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
for j := i; j < len(queries); j++ {
|
||||
results[j] = botFanoutErrorResult(j, queries[j], ctx.Err())
|
||||
}
|
||||
break schedule
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(i int, q string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err := botFanoutPanicError(q, r)
|
||||
results[i] = botFanoutResult{
|
||||
Index: i,
|
||||
Query: q,
|
||||
ErrMsg: contactFanoutErrorSummary(err),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
}()
|
||||
results[i] = runOneBotQuery(ctx, runtime, i, q, filter)
|
||||
}(i, q)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
resp, err := buildBotFanoutResponse(queries, results)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
failed, hasMoreCount := 0, 0
|
||||
for _, qs := range resp.Queries {
|
||||
if qs.Error != "" {
|
||||
failed++
|
||||
}
|
||||
if qs.HasMore {
|
||||
hasMoreCount++
|
||||
}
|
||||
}
|
||||
|
||||
runtime.OutFormat(resp, &output.Meta{Count: len(resp.Bots)}, func(w io.Writer) {
|
||||
if len(resp.Bots) == 0 {
|
||||
fmt.Fprintln(w, "No bots found.")
|
||||
return
|
||||
}
|
||||
output.PrintTable(w, prettyBotFanoutRows(resp.Bots))
|
||||
})
|
||||
|
||||
if isFanoutSummaryFormat(runtime.Format) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\n%d queries, %d total matches; %d failed, %d with has_more\n",
|
||||
len(queries), len(resp.Bots), failed, hasMoreCount)
|
||||
}
|
||||
// The counts above say how many queries failed but not which, and only the
|
||||
// json envelope carries queries[].error / queries[].notice. Without this an
|
||||
// agent reading csv or a table sees "1 failed" with no way to learn the
|
||||
// keyword or the reason, and a notice disappears entirely.
|
||||
if !botSearchStdoutCarriesEnvelope(runtime.Format) {
|
||||
for _, qs := range resp.Queries {
|
||||
if qs.Error != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "failed: %q — %s\n", qs.Query, qs.Error)
|
||||
}
|
||||
if qs.Notice != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "notice: %q — %s\n", qs.Query, qs.Notice)
|
||||
}
|
||||
if qs.HasMore {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "has_more: %q — more matches exist; narrow this keyword\n", qs.Query)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prettyBotFanoutRows(bots []fanoutBot) []map[string]interface{} {
|
||||
rows := make([]map[string]interface{}, 0, len(bots))
|
||||
for _, bot := range bots {
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"matched_query": bot.MatchedQuery,
|
||||
"name": bot.Name,
|
||||
"description": common.TruncateStr(bot.Description, 50),
|
||||
"is_agent": bot.IsAgent,
|
||||
"enable_join_group": bot.EnableJoinGroup,
|
||||
"open_id": bot.OpenID,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
684
shortcuts/contact/contact_search_bot_fanout_test.go
Normal file
684
shortcuts/contact/contact_search_bot_fanout_test.go
Normal file
@@ -0,0 +1,684 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBotFanoutErrorResultNilErrorIsSuccess(t *testing.T) {
|
||||
r := botFanoutErrorResult(3, "会议助手", nil)
|
||||
if r.ErrMsg != "" || r.Err != nil {
|
||||
t.Fatalf("nil error must stay a success result: %+v", r)
|
||||
}
|
||||
if r.Index != 3 || r.Query != "会议助手" {
|
||||
t.Fatalf("index/query must survive: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutAssembleOrderAndShape(t *testing.T) {
|
||||
results := []botFanoutResult{
|
||||
{Index: 1, Query: "日报", Bots: []searchBot{{OpenID: "ou_b"}}, HasMore: true},
|
||||
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a1"}, {OpenID: "ou_a2"}}},
|
||||
{Index: 2, Query: "审批", ErrMsg: "API 1: nope"},
|
||||
}
|
||||
resp, err := buildBotFanoutResponse([]string{"会议", "日报", "审批"}, results)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Results are emitted in query order even though the workers finished out of
|
||||
// order, and a failed query contributes no rows.
|
||||
wantRows := []struct {
|
||||
openID, matched string
|
||||
}{{"ou_a1", "会议"}, {"ou_a2", "会议"}, {"ou_b", "日报"}}
|
||||
if len(resp.Bots) != len(wantRows) {
|
||||
t.Fatalf("bots length: got %d, want %d", len(resp.Bots), len(wantRows))
|
||||
}
|
||||
for i, w := range wantRows {
|
||||
if resp.Bots[i].OpenID != w.openID || resp.Bots[i].MatchedQuery != w.matched {
|
||||
t.Errorf("bots[%d]: got %+v, want %s/%s", i, resp.Bots[i], w.openID, w.matched)
|
||||
}
|
||||
}
|
||||
|
||||
want := []querySummary{
|
||||
{Query: "会议"},
|
||||
{Query: "日报", HasMore: true},
|
||||
{Query: "审批", Error: "API 1: nope"},
|
||||
}
|
||||
if len(resp.Queries) != len(want) {
|
||||
t.Fatalf("queries length: got %d, want %d (every query is enumerated)", len(resp.Queries), len(want))
|
||||
}
|
||||
for i, w := range want {
|
||||
if resp.Queries[i] != w {
|
||||
t.Errorf("queries[%d]: got %+v, want %+v", i, resp.Queries[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutAssembleAllFailedReturnsTypedError(t *testing.T) {
|
||||
results := []botFanoutResult{
|
||||
{Index: 0, Query: "会议", ErrMsg: "API 99991663: rate limit", Err: errs.NewAPIError(errs.SubtypeRateLimit, "rate limit").WithCode(99991663)},
|
||||
{Index: 1, Query: "日报", ErrMsg: "HTTP 500 Internal Server Error"},
|
||||
}
|
||||
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when every query fails")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed problem, got %T: %v", err, err)
|
||||
}
|
||||
// The first failure's classification must survive, so the caller can tell a
|
||||
// rate limit apart from a transport fault.
|
||||
if problem.Code != 99991663 || problem.Subtype != errs.SubtypeRateLimit {
|
||||
t.Errorf("problem: got %d/%s, want 99991663/%s", problem.Code, problem.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
// Agents grep the count and the first failure out of this message.
|
||||
for _, want := range []string{"all 2 queries failed", "rate limit"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("message must contain %q; got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutAssemblePartialFailureSucceeds(t *testing.T) {
|
||||
results := []botFanoutResult{
|
||||
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}},
|
||||
{Index: 1, Query: "日报", ErrMsg: "API 1: nope"},
|
||||
}
|
||||
resp, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
|
||||
if err != nil {
|
||||
t.Fatalf("one failure out of two must not fail the call: %v", err)
|
||||
}
|
||||
if len(resp.Bots) != 1 || resp.Queries[1].Error == "" {
|
||||
t.Fatalf("partial failure shape: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutTerminalContextOverridesPartialSuccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantSubtype errs.Subtype
|
||||
}{
|
||||
{name: "cancelled", err: context.Canceled, wantSubtype: errs.SubtypeNetworkTransport},
|
||||
{name: "deadline", err: context.DeadlineExceeded, wantSubtype: errs.SubtypeNetworkTimeout},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
results := []botFanoutResult{
|
||||
{Index: 0, Query: "会议", Bots: []searchBot{{OpenID: "ou_a"}}},
|
||||
botFanoutErrorResult(1, "日报", tt.err),
|
||||
}
|
||||
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
|
||||
if err == nil {
|
||||
t.Fatal("terminal context error must fail the batch after a partial success")
|
||||
}
|
||||
if !errors.Is(err, tt.err) {
|
||||
t.Fatalf("error must preserve %v as its cause: %v", tt.err, err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != tt.wantSubtype {
|
||||
t.Fatalf("problem: got %+v, want network/%s", problem, tt.wantSubtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutResponseHasNoTopLevelHasMore(t *testing.T) {
|
||||
resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议", HasMore: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
// has_more is per query in the sidecar; a single top-level flag would hide
|
||||
// which keyword was truncated.
|
||||
if _, ok := envelope["has_more"]; ok {
|
||||
t.Fatalf("fanout must not surface a top-level has_more: %s", raw)
|
||||
}
|
||||
if !envelope["queries"].([]interface{})[0].(map[string]interface{})["has_more"].(bool) {
|
||||
t.Fatalf("per-query has_more lost: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutEmptyBotsSerializesAsArray(t *testing.T) {
|
||||
resp, err := buildBotFanoutResponse([]string{"会议"}, []botFanoutResult{{Index: 0, Query: "会议"}})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"bots":[]`) {
|
||||
t.Fatalf("empty bots must serialize as [], not null: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrettyBotFanoutRowsLeadWithMatchedQuery(t *testing.T) {
|
||||
rows := prettyBotFanoutRows([]fanoutBot{{
|
||||
searchBot: searchBot{OpenID: "ou_a", Name: "会议助手", Description: strings.Repeat("长", 80)},
|
||||
MatchedQuery: "会议",
|
||||
}})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows: %d", len(rows))
|
||||
}
|
||||
if rows[0]["matched_query"] != "会议" {
|
||||
t.Errorf("matched_query missing: %+v", rows[0])
|
||||
}
|
||||
if got := rows[0]["description"].(string); len([]rune(got)) > 51 {
|
||||
t.Errorf("description must be truncated like the single-search table: %d runes", len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutValidationRejectsQueryAndQueriesTogether(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
setBotSearchFlag(t, cmd, "query", "会议")
|
||||
setBotSearchFlag(t, cmd, "queries", "会议,日报")
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
|
||||
err := validateBotSearch(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected mutual-exclusion error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem: %+v ok=%v", problem, ok)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutValidationLimits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
queries string
|
||||
wantParam string
|
||||
}{
|
||||
{name: "nothing parses", queries: " , , ", wantParam: "--queries"},
|
||||
{name: "over the entry cap", queries: strings.TrimSuffix(strings.Repeat("q%d,", maxFanoutQueries+1), ","), wantParam: "--queries"},
|
||||
{name: "entry too long", queries: strings.Repeat("会", maxBotSearchQueryChars+1), wantParam: "--queries"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
queries := tt.queries
|
||||
if strings.Contains(queries, "%d") {
|
||||
parts := make([]string, 0, maxFanoutQueries+1)
|
||||
for i := 0; i <= maxFanoutQueries; i++ {
|
||||
parts = append(parts, fmt.Sprintf("q%d", i))
|
||||
}
|
||||
queries = strings.Join(parts, ",")
|
||||
}
|
||||
cmd := newBotSearchTestCommand()
|
||||
setBotSearchFlag(t, cmd, "queries", queries)
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
assertBotSearchValidationProblem(t, validateBotSearch(runtime), tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --queries alone is enough: the single-search "--query is required" rule must not
|
||||
// leak into fanout mode.
|
||||
func TestBotFanoutValidationQueriesAloneIsValid(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
setBotSearchFlag(t, cmd, "queries", "会议助手,日报助手")
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
if err := validateBotSearch(runtime); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutFilterAppliedToEveryQuery(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
stub := botSearchStub(botSearchURL+"?page_size=20", "")
|
||||
stub.Reusable = true
|
||||
registry.Register(stub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "会议,日报", "--has-chatted", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(stub.CapturedBodies) != 2 {
|
||||
t.Fatalf("expected one request per query, got %d", len(stub.CapturedBodies))
|
||||
}
|
||||
seen := make(map[string]bool, len(stub.CapturedBodies))
|
||||
for i, raw := range stub.CapturedBodies {
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("unmarshal req %d: %v", i, err)
|
||||
}
|
||||
seen[fmt.Sprint(body["query"])] = true
|
||||
filter, ok := body["filter"].(map[string]interface{})
|
||||
if !ok || filter["has_chatter"] != true {
|
||||
t.Fatalf("filter must ride along with every query: %#v", body)
|
||||
}
|
||||
}
|
||||
for _, q := range []string{"会议", "日报"} {
|
||||
if !seen[q] {
|
||||
t.Fatalf("query %q never issued; saw %v", q, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutMatchedQueryFidelityAndDedup(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
dedupStub := botSearchStub(botSearchURL+"?page_size=20", "")
|
||||
dedupStub.Reusable = true
|
||||
registry.Register(dedupStub)
|
||||
|
||||
// " 会议 " and "会议" collapse to one query; the duplicate must not double the
|
||||
// requests or the rows.
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", " 会议 ,会议", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data botFanoutResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(envelope.Data.Queries) != 1 || envelope.Data.Queries[0].Query != "会议" {
|
||||
t.Fatalf("dedup failed: %+v", envelope.Data.Queries)
|
||||
}
|
||||
for _, bot := range envelope.Data.Bots {
|
||||
if bot.MatchedQuery != "会议" {
|
||||
t.Fatalf("matched_query fidelity: %+v", bot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutConcurrencyCap(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
|
||||
var inFlight, peak int32
|
||||
stub := botSearchStub(botSearchURL+"?page_size=20", "")
|
||||
stub.Reusable = true
|
||||
stub.OnMatch = func(req *http.Request) {
|
||||
cur := atomic.AddInt32(&inFlight, 1)
|
||||
defer atomic.AddInt32(&inFlight, -1)
|
||||
for {
|
||||
p := atomic.LoadInt32(&peak)
|
||||
if cur <= p || atomic.CompareAndSwapInt32(&peak, p, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
registry.Register(stub)
|
||||
|
||||
queries := []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if peak > fanoutConcurrency {
|
||||
t.Errorf("concurrency peak = %d, want <= %d", peak, fanoutConcurrency)
|
||||
}
|
||||
if peak < 2 {
|
||||
t.Errorf("concurrency peak = %d, want >= 2 so the test actually observes parallelism", peak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutPanicFailsBatch(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
panicCause := errors.New("synthetic test panic")
|
||||
|
||||
boom := botSearchStub(botSearchURL, "")
|
||||
boom.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"boom"`) }
|
||||
boom.OnMatch = func(req *http.Request) { panic(panicCause) }
|
||||
registry.Register(boom)
|
||||
|
||||
okStub := botSearchStub(botSearchURL, "")
|
||||
okStub.Reusable = true
|
||||
registry.Register(okStub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "ok,boom,fine", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("a panicking query must fail the batch")
|
||||
}
|
||||
if !errors.Is(err, panicCause) {
|
||||
t.Fatalf("panic cause must be preserved: %v", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("problem: got %+v, want internal/%s", problem, errs.SubtypeUnknown)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("terminal failure must not write a success envelope: %s", stdout.String())
|
||||
}
|
||||
for _, marker := range []string{"goroutine ", ".go:", "runtime."} {
|
||||
if strings.Contains(stderr.String(), marker) {
|
||||
t.Errorf("stderr leaked stack-trace marker %q: %s", marker, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutAllQueriesFailingExitsNonZero(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: botSearchURL,
|
||||
Reusable: true,
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"reason": "boom"},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("every query failing must surface as a command error")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("expected a typed problem, got %T: %v", err, err)
|
||||
}
|
||||
// The first failure's upstream status and the all-failed mode must both survive,
|
||||
// so a caller can classify instead of seeing a generic internal error.
|
||||
for _, want := range []string{"500", "all 2 queries failed"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("message must contain %q; got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutPartialFailureKeepsNoticeAndSucceeds(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
|
||||
broken := botSearchStub(botSearchURL, "")
|
||||
broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) }
|
||||
broken.Status = 500
|
||||
broken.Body = map[string]interface{}{"reason": "boom"}
|
||||
registry.Register(broken)
|
||||
|
||||
okStub := botSearchStub(botSearchURL, "")
|
||||
okStub.Reusable = true
|
||||
registry.Register(okStub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "会议,日报", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("one failing query must not fail the batch: %v", err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data botFanoutResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
|
||||
const wantNotice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
// Assert the notice itself, not just that some row survived: the surviving
|
||||
// query's server remark has to reach the caller both at the top level and in
|
||||
// its own sidecar entry.
|
||||
if envelope.Data.Notice != wantNotice {
|
||||
t.Errorf("top-level notice: got %q, want %q", envelope.Data.Notice, wantNotice)
|
||||
}
|
||||
if len(envelope.Data.Queries) != 2 {
|
||||
t.Fatalf("both queries must be enumerated: %+v", envelope.Data.Queries)
|
||||
}
|
||||
if envelope.Data.Queries[0].Notice != wantNotice {
|
||||
t.Errorf("surviving query notice: got %q, want %q", envelope.Data.Queries[0].Notice, wantNotice)
|
||||
}
|
||||
if envelope.Data.Queries[0].Error != "" {
|
||||
t.Errorf("surviving query must carry no error: %q", envelope.Data.Queries[0].Error)
|
||||
}
|
||||
if !strings.Contains(envelope.Data.Queries[1].Error, "500") {
|
||||
t.Errorf("failed query must carry the upstream status: %q", envelope.Data.Queries[1].Error)
|
||||
}
|
||||
// Only the surviving query contributes rows.
|
||||
if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].MatchedQuery != "会议" {
|
||||
t.Fatalf("bots: %+v", envelope.Data.Bots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutCSVCarriesMatchedQueryAndSummary(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
stub := botSearchStub(botSearchURL, "")
|
||||
stub.Reusable = true
|
||||
registry.Register(stub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "会议,日报", "--format", "csv", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "matched_query") {
|
||||
t.Errorf("csv must expose matched_query so rows can be traced to a keyword: %s", stdout.String())
|
||||
}
|
||||
// csv is in the summary format set, so the batch counters belong on stderr.
|
||||
if !strings.Contains(stderr.String(), "2 queries, 2 total matches") || !strings.Contains(stderr.String(), "0 failed") {
|
||||
t.Errorf("stderr summary must report the batch counters: %s", stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), "total bots") {
|
||||
t.Errorf("summary must count matches rather than imply unique bots: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutNDJSONKeepsStdoutClean(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
stub := botSearchStub(botSearchURL, "")
|
||||
stub.Reusable = true
|
||||
registry.Register(stub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "会议,日报", "--format", "ndjson", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
// ndjson is a machine format outside the summary set: every stdout line must
|
||||
// parse, and the counters must not be mixed in.
|
||||
for i, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var row map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
||||
t.Fatalf("stdout line %d is not JSON: %q", i, line)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stderr.String(), "queries,") {
|
||||
t.Errorf("ndjson must not emit the summary line: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotFanoutCancelledSchedulingFailsQueuedQueries drives the real command so
|
||||
// the scheduler inside executeBotSearchFanout — not just runOneBotQuery — sees
|
||||
// the cancellation. Queueing more keywords than fanoutConcurrency while every
|
||||
// worker is parked keeps all semaphore slots held, so the queued keywords can
|
||||
// only leave the loop through its ctx.Done() branch.
|
||||
func TestBotFanoutCancelledSchedulingFailsQueuedQueries(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
started := make(chan struct{})
|
||||
var once sync.Once
|
||||
stub := botSearchStub(botSearchURL+"?page_size=20", "")
|
||||
stub.Reusable = true
|
||||
stub.OnMatch = func(*http.Request) {
|
||||
once.Do(func() { close(started) })
|
||||
<-ctx.Done() // hold the slot so later keywords must queue on the semaphore
|
||||
}
|
||||
registry.Register(stub)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second): // never leave the workers parked
|
||||
}
|
||||
cancel()
|
||||
}()
|
||||
|
||||
queries := make([]string, 0, fanoutConcurrency+3)
|
||||
for i := 0; i < fanoutConcurrency+3; i++ {
|
||||
queries = append(queries, fmt.Sprintf("q%d", i))
|
||||
}
|
||||
|
||||
err := mountAndRunContext(t, ctx, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", strings.Join(queries, ","), "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("a cancelled batch must surface as a command error")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation cause must be preserved: %v", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotFanoutCancelledContextShortCircuitsBeforeRequest pins the other half:
|
||||
// a queued worker must fail on the pre-check instead of issuing its request.
|
||||
func TestBotFanoutCancelledContextShortCircuitsBeforeRequest(t *testing.T) {
|
||||
results := make([]botFanoutResult, 0, 2)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
for i, q := range []string{"会议", "日报"} {
|
||||
results = append(results, runOneBotQuery(ctx, nil, i, q, nil))
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.ErrMsg == "" {
|
||||
t.Fatalf("a cancelled context must short-circuit before the request: %+v", r)
|
||||
}
|
||||
}
|
||||
_, err := buildBotFanoutResponse([]string{"会议", "日报"}, results)
|
||||
if err == nil {
|
||||
t.Fatal("all queries cancelled must surface as an error")
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation cause must be preserved: %v", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem: got %+v, want network/%s", problem, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotFanoutDryRunPreviewsOneRequestPerKeyword(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
setBotSearchFlag(t, cmd, "queries", "会议, 日报 ,会议")
|
||||
setBotSearchFlag(t, cmd, "chat-ids", "oc_a")
|
||||
setBotSearchFlag(t, cmd, "has-chatted", "true")
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
|
||||
raw, err := json.Marshal(ContactSearchBot.DryRun(context.Background(), runtime))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
var preview struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body struct {
|
||||
Query string `json:"query"`
|
||||
Filter *struct {
|
||||
ChatIDs []string `json:"chat_ids"`
|
||||
HasChatter bool `json:"has_chatter"`
|
||||
} `json:"filter"`
|
||||
} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &preview); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, raw)
|
||||
}
|
||||
|
||||
// Deduped, so the repeated keyword previews once — the preview has to match
|
||||
// the requests Execute would actually issue.
|
||||
if len(preview.API) != 2 {
|
||||
t.Fatalf("expected one previewed request per deduped keyword, got %d: %s", len(preview.API), raw)
|
||||
}
|
||||
seen := make([]string, 0, len(preview.API))
|
||||
for i, call := range preview.API {
|
||||
if call.Method != "POST" || call.URL != botSearchURL {
|
||||
t.Errorf("api[%d]: got %s %s", i, call.Method, call.URL)
|
||||
}
|
||||
if call.Params["page_size"] != float64(20) {
|
||||
t.Errorf("api[%d] page_size: %v", i, call.Params["page_size"])
|
||||
}
|
||||
if _, ok := call.Params["page_token"]; ok {
|
||||
t.Errorf("api[%d] must not preview a page_token: %v", i, call.Params)
|
||||
}
|
||||
// The filter rides along with every keyword, not just the first.
|
||||
if call.Body.Filter == nil || !call.Body.Filter.HasChatter ||
|
||||
len(call.Body.Filter.ChatIDs) != 1 || call.Body.Filter.ChatIDs[0] != "oc_a" {
|
||||
t.Errorf("api[%d] filter: %+v", i, call.Body.Filter)
|
||||
}
|
||||
seen = append(seen, call.Body.Query)
|
||||
}
|
||||
if fmt.Sprint(seen) != fmt.Sprint([]string{"会议", "日报"}) {
|
||||
t.Errorf("previewed keywords: got %v, want [会议 日报]", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// The summary counts how many queries failed but never says which or why, and
|
||||
// only json carries queries[].error. Without a per-query line on stderr an agent
|
||||
// reading csv sees "1 failed" and cannot recover the keyword or the reason.
|
||||
func TestBotFanoutFailedQueryIsNamedOnStderr(t *testing.T) {
|
||||
for _, format := range []string{"csv", "table", "pretty", "ndjson"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
broken := botSearchStub(botSearchURL, "")
|
||||
broken.BodyFilter = func(b []byte) bool { return strings.Contains(string(b), `"日报"`) }
|
||||
broken.Status = 500
|
||||
broken.Body = map[string]interface{}{"reason": "boom"}
|
||||
registry.Register(broken)
|
||||
okStub := botSearchStub(botSearchURL, "")
|
||||
okStub.Reusable = true
|
||||
registry.Register(okStub)
|
||||
|
||||
if err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--queries", "会议,日报", "--format", format, "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("one failing query must not fail the batch: %v", err)
|
||||
}
|
||||
for _, want := range []string{"日报", "500"} {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("%s: stderr must name the failed query and its reason (missing %q)\nstderr:\n%s",
|
||||
format, want, stderr.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
724
shortcuts/contact/contact_search_bot_test.go
Normal file
724
shortcuts/contact/contact_search_bot_test.go
Normal file
@@ -0,0 +1,724 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newBotSearchTestCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("query", "", "")
|
||||
cmd.Flags().String("chat-ids", "", "")
|
||||
cmd.Flags().Bool("has-chatted", false, "")
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
cmd.Flags().String("queries", "", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func botSearchDefaultConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_self",
|
||||
}
|
||||
}
|
||||
|
||||
func setBotSearchFlag(t *testing.T, cmd *cobra.Command, name, value string) {
|
||||
t.Helper()
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s=%q: %v", name, value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBotSearchValidationProblem(t *testing.T, err error, wantParam string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem: got %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if validationErr.Param != wantParam {
|
||||
t.Fatalf("param: got %q, want %q", validationErr.Param, wantParam)
|
||||
}
|
||||
}
|
||||
|
||||
// assertBotSearchValidationParams covers the errors that name several flags via
|
||||
// WithParams; those leave the single Param empty on purpose, so an agent reading
|
||||
// the envelope sees every flag that could satisfy the requirement.
|
||||
func assertBotSearchValidationParams(t *testing.T, err error, wantParams []string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem: %+v ok=%v", problem, ok)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
got := make([]string, 0, len(validationErr.Params))
|
||||
for _, p := range validationErr.Params {
|
||||
if p.Reason == "" {
|
||||
t.Errorf("param %q has no reason; agents read it to pick a recovery", p.Name)
|
||||
}
|
||||
got = append(got, p.Name)
|
||||
}
|
||||
if fmt.Sprint(got) != fmt.Sprint(wantParams) {
|
||||
t.Fatalf("params: got %v, want %v", got, wantParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBotSearchErrors(t *testing.T) {
|
||||
chatIDs := make([]string, 101)
|
||||
for i := range chatIDs {
|
||||
chatIDs[i] = fmt.Sprintf("oc_%03d", i)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantParam string
|
||||
wantParams []string // set instead of wantParam when the error names several flags
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "keyword missing",
|
||||
wantParams: []string{"--query", "--queries"},
|
||||
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
|
||||
},
|
||||
{
|
||||
name: "query over 50 characters",
|
||||
flags: map[string]string{"query": strings.Repeat("中", 51)},
|
||||
wantParam: "--query",
|
||||
wantMessage: "--query: length must be between 1 and 50 characters",
|
||||
},
|
||||
{
|
||||
name: "chat ids parse empty",
|
||||
flags: map[string]string{"query": "x", "chat-ids": " , , "},
|
||||
wantParam: "--chat-ids",
|
||||
wantMessage: "--chat-ids: no valid chat_id parsed from \", ,\" (separate entries with ',')",
|
||||
},
|
||||
{
|
||||
name: "over 100 chat ids",
|
||||
flags: map[string]string{"query": "x", "chat-ids": strings.Join(chatIDs, ",")},
|
||||
wantParam: "--chat-ids",
|
||||
wantMessage: "--chat-ids: must be at most 100 entries",
|
||||
},
|
||||
{
|
||||
name: "invalid chat id",
|
||||
flags: map[string]string{"query": "x", "chat-ids": "bad"},
|
||||
wantParam: "--chat-ids",
|
||||
wantMessage: "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)",
|
||||
},
|
||||
{
|
||||
// With a keyword present the keyword errors win, exactly as +search-user
|
||||
// orders them; the =false check must not be hoisted above these.
|
||||
name: "mutually exclusive keywords outrank has chatted false",
|
||||
flags: map[string]string{"query": "x", "queries": "y", "has-chatted": "false"},
|
||||
wantParams: []string{"--query", "--queries"},
|
||||
wantMessage: "--query and --queries are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "query length outranks has chatted false",
|
||||
flags: map[string]string{"query": strings.Repeat("中", 51), "has-chatted": "false"},
|
||||
wantParam: "--query",
|
||||
wantMessage: "--query: length must be between 1 and 50 characters",
|
||||
},
|
||||
{
|
||||
// With no keyword at all the explicit =false is the more specific mistake,
|
||||
// so it wins over the missing-keyword error rather than costing a second
|
||||
// round trip. Matches which error +search-user reports first.
|
||||
name: "has chatted false without a keyword",
|
||||
flags: map[string]string{"has-chatted": "false"},
|
||||
wantParam: "--has-chatted",
|
||||
wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
|
||||
},
|
||||
{
|
||||
name: "has chatted false",
|
||||
flags: map[string]string{"query": "x", "has-chatted": "false"},
|
||||
wantParam: "--has-chatted",
|
||||
wantMessage: "--has-chatted: pass the flag to enable the filter; omit it to disable filtering (=false is rejected to prevent silent wrong results)",
|
||||
},
|
||||
{
|
||||
name: "page size below one",
|
||||
flags: map[string]string{"query": "x", "page-size": "0"},
|
||||
wantParam: "--page-size",
|
||||
wantMessage: "--page-size: must be between 1 and 30",
|
||||
},
|
||||
{
|
||||
name: "page size over 30",
|
||||
flags: map[string]string{"query": "x", "page-size": "31"},
|
||||
wantParam: "--page-size",
|
||||
wantMessage: "--page-size: must be between 1 and 30",
|
||||
},
|
||||
{
|
||||
name: "chat ids without a keyword",
|
||||
flags: map[string]string{"chat-ids": "oc_a"},
|
||||
wantParams: []string{"--query", "--queries"},
|
||||
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
|
||||
},
|
||||
{
|
||||
name: "has chatted without a keyword",
|
||||
flags: map[string]string{"has-chatted": "true"},
|
||||
wantParams: []string{"--query", "--queries"},
|
||||
wantMessage: "specify --query or --queries: --chat-ids and --has-chatted shape a keyword search but cannot enumerate bots on their own (the API answers a filter-only request with an empty list)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
for name, value := range tt.flags {
|
||||
setBotSearchFlag(t, cmd, name, value)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
err := validateBotSearch(runtime)
|
||||
if len(tt.wantParams) > 0 {
|
||||
assertBotSearchValidationParams(t, err, tt.wantParams)
|
||||
} else {
|
||||
assertBotSearchValidationProblem(t, err, tt.wantParam)
|
||||
}
|
||||
if err.Error() != tt.wantMessage {
|
||||
t.Fatalf("message: got %q, want %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBotSearchPassingCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
}{
|
||||
{name: "query only", flags: map[string]string{"query": "x"}},
|
||||
{name: "query and chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}},
|
||||
{name: "query and has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}},
|
||||
{name: "all filters", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}},
|
||||
{name: "page size upper boundary", flags: map[string]string{"query": "x", "page-size": "30"}},
|
||||
// An explicitly blank string flag reads as "no filter", matching how
|
||||
// +search-user treats --user-ids / --queries. Only a non-blank value that
|
||||
// parses to zero entries is an error.
|
||||
{name: "blank chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": ""}},
|
||||
{name: "whitespace chat ids ignored", flags: map[string]string{"query": "x", "chat-ids": " "}},
|
||||
// Duplicates collapse before the cap is checked, so 101 copies of one chat
|
||||
// is one entry — matching how --user-ids is resolved for +search-user.
|
||||
{name: "duplicate chat ids collapse under the cap", flags: map[string]string{
|
||||
"query": "x", "chat-ids": strings.TrimSuffix(strings.Repeat("oc_a,", 101), ","),
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
for name, value := range tt.flags {
|
||||
setBotSearchFlag(t, cmd, name, value)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
if err := validateBotSearch(runtime); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBotSearchQueryRuneBoundary(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
query string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "50 CJK characters", query: strings.Repeat("中", 50)},
|
||||
{name: "51 CJK characters", query: strings.Repeat("中", 51), wantError: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
setBotSearchFlag(t, cmd, "query", tt.query)
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
err := validateBotSearch(runtime)
|
||||
if tt.wantError {
|
||||
assertBotSearchValidationProblem(t, err, "--query")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBotSearchBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantJSON string
|
||||
}{
|
||||
{name: "query only", flags: map[string]string{"query": "x"}, wantJSON: `{"query":"x"}`},
|
||||
{name: "chat ids", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
|
||||
{name: "chat id URL normalized", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
|
||||
{name: "has chatted", flags: map[string]string{"query": "x", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"has_chatter":true}}`},
|
||||
{name: "all fields", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_b", "has-chatted": "true"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"],"has_chatter":true}}`},
|
||||
// A blank --chat-ids must not materialize an empty filter object.
|
||||
{name: "blank chat ids omit filter", flags: map[string]string{"query": "x", "chat-ids": " "}, wantJSON: `{"query":"x"}`},
|
||||
// Deduped after normalization, so a repeated id and a URL naming the same
|
||||
// chat both collapse into one entry instead of burning the server's quota.
|
||||
{name: "duplicate chat ids deduped", flags: map[string]string{"query": "x", "chat-ids": "oc_a,oc_a,oc_b"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a","oc_b"]}}`},
|
||||
{name: "URL and bare id dedupe to one", flags: map[string]string{"query": "x", "chat-ids": "https://example.feishu.cn/foo/oc_a,oc_a"}, wantJSON: `{"query":"x","filter":{"chat_ids":["oc_a"]}}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := newBotSearchTestCommand()
|
||||
for name, value := range tt.flags {
|
||||
setBotSearchFlag(t, cmd, name, value)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContext(cmd, botSearchDefaultConfig())
|
||||
body, err := buildBotSearchBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("build body: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
if string(raw) != tt.wantJSON {
|
||||
t.Fatalf("body: got %s, want %s", raw, tt.wantJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBotDisplayInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantName string
|
||||
wantDescription string
|
||||
wantSegments []string
|
||||
}{
|
||||
// Whole name highlighted, description on line two.
|
||||
{name: "whole name highlighted", raw: "<h>甲乙丙</h>\n一句话简介", wantName: "甲乙丙", wantDescription: "一句话简介", wantSegments: []string{"甲乙丙"}},
|
||||
// Two highlighted runs split by a plain character: stripping tags has to
|
||||
// rejoin them into one name.
|
||||
{name: "two highlighted runs", raw: "<h>甲乙</h>丁<h>丙</h>\n另一句简介", wantName: "甲乙丁丙", wantDescription: "另一句简介", wantSegments: []string{"甲乙", "丙"}},
|
||||
// Highlight at the end plus a trailing newline: line two exists but is empty.
|
||||
{name: "trailing newline empty description", raw: "戊己的<h>庚辛</h>\n", wantName: "戊己的庚辛", wantSegments: []string{"庚辛"}},
|
||||
// Single highlighted character in the middle of the name.
|
||||
{name: "mid-name highlight", raw: "壬癸<h>子</h>丑\n第二行简介", wantName: "壬癸子丑", wantDescription: "第二行简介", wantSegments: []string{"子"}},
|
||||
{name: "no newline", raw: "寅卯", wantName: "寅卯", wantSegments: []string{}},
|
||||
{name: "html entities", raw: "<h>Lark</h>部门成员&仓库\n来自飞书多维表格", wantName: "Lark部门成员&仓库", wantDescription: "来自飞书多维表格", wantSegments: []string{"Lark"}},
|
||||
{name: "html entity in highlight", raw: "名称<h>&</h>工具", wantName: "名称&工具", wantSegments: []string{"&"}},
|
||||
{name: "empty", raw: "", wantSegments: []string{}},
|
||||
{name: "first non-empty line", raw: "\n\n真名", wantName: "真名", wantSegments: []string{}},
|
||||
// A blank first line must not make the description echo the name back and
|
||||
// swallow the real description on the line after it.
|
||||
{name: "blank first line keeps description", raw: "\n真名\n简介", wantName: "真名", wantDescription: "简介", wantSegments: []string{}},
|
||||
{name: "blank first line without description", raw: "\n真名", wantName: "真名", wantSegments: []string{}},
|
||||
// A highlight with no text carries nothing; an empty match segment is junk
|
||||
// in the envelope. Which line the name comes from is left unchanged.
|
||||
{name: "empty highlight yields no segment", raw: "<h></h>\n简介", wantName: "简介", wantSegments: []string{}},
|
||||
// The non-greedy pattern pairs a stray `<h>` with the next `</h>`, so the
|
||||
// capture can carry a tag the name and description already dropped.
|
||||
{name: "nested highlight", raw: "<h>甲<h>乙</h></h>\n简介", wantName: "甲乙", wantDescription: "简介", wantSegments: []string{"甲乙"}},
|
||||
{name: "dangling open tag", raw: "<h><h>甲</h>\n简介", wantName: "甲", wantDescription: "简介", wantSegments: []string{"甲"}},
|
||||
{name: "unclosed highlight", raw: "<h>甲乙\n简介", wantName: "甲乙", wantDescription: "简介", wantSegments: []string{}},
|
||||
// A literal `<h>` in a name arrives escaped, so it must survive: tags are
|
||||
// stripped before unescaping. Swapping that order eats the name's own text.
|
||||
{name: "escaped angle brackets are name text", raw: "名称<h>工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{}},
|
||||
{name: "escaped angle brackets inside a highlight", raw: "<h>名称<h></h>工具\n简介", wantName: "名称<h>工具", wantDescription: "简介", wantSegments: []string{"名称<h>"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
name, description, segments := parseBotDisplayInfo(tt.raw)
|
||||
if name != tt.wantName || description != tt.wantDescription {
|
||||
t.Fatalf("name/description: got %q/%q, want %q/%q", name, description, tt.wantName, tt.wantDescription)
|
||||
}
|
||||
if segments == nil {
|
||||
t.Fatal("match segments must be an empty slice, not nil")
|
||||
}
|
||||
if fmt.Sprint(segments) != fmt.Sprint(tt.wantSegments) {
|
||||
t.Fatalf("match segments: got %v, want %v", segments, tt.wantSegments)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBotsMapsEveryField(t *testing.T) {
|
||||
data := &botSearchAPIData{Items: []botSearchAPIItem{
|
||||
{
|
||||
ID: "ou_with_chat",
|
||||
DisplayInfo: "<h>甲乙丙</h>\n一句话简介",
|
||||
MetaData: botSearchAPIMeta{
|
||||
TenantID: "1", EnableJoinGroup: true, ChatID: "oc_p2p", IsAgent: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "ou_without_chat",
|
||||
DisplayInfo: "",
|
||||
MetaData: botSearchAPIMeta{TenantID: "1"},
|
||||
},
|
||||
}}
|
||||
|
||||
bots := projectBots(data)
|
||||
if len(bots) != 2 {
|
||||
t.Fatalf("bots: got %d, want 2", len(bots))
|
||||
}
|
||||
first := bots[0]
|
||||
if first.OpenID != "ou_with_chat" || first.Name != "甲乙丙" || first.Description != "一句话简介" ||
|
||||
first.ChatID != "oc_p2p" || !first.EnableJoinGroup || !first.IsAgent || first.TenantID != "1" ||
|
||||
fmt.Sprint(first.MatchSegments) != "[甲乙丙]" {
|
||||
t.Fatalf("first bot mapping: %+v", first)
|
||||
}
|
||||
second := bots[1]
|
||||
if second.Name != "" || second.ChatID != "" {
|
||||
t.Fatalf("empty source fields must stay empty: %+v", second)
|
||||
}
|
||||
raw, err := json.Marshal(searchBotResponse{Bots: bots})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal response: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"chat_id":""`) {
|
||||
t.Fatalf("empty chat_id must still be emitted: %s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"name":""`) {
|
||||
t.Fatalf("empty name must not fall back to open_id: %s", raw)
|
||||
}
|
||||
if strings.Contains(string(raw), `"has_chatted"`) {
|
||||
t.Fatalf("chat_id presence must not be exposed as a has_chatted signal: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBotsEmptySerializesAsArray(t *testing.T) {
|
||||
bots := projectBots(&botSearchAPIData{Items: []botSearchAPIItem{}})
|
||||
if bots == nil {
|
||||
t.Fatal("bots must be an empty slice, not nil")
|
||||
}
|
||||
raw, err := json.Marshal(searchBotResponse{Bots: bots})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal response: %v", err)
|
||||
}
|
||||
if string(raw) != `{"bots":[],"has_more":false}` {
|
||||
t.Fatalf("response: got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func botSearchStub(url string, pageToken string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: url,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"has_more": true,
|
||||
"page_token": pageToken,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "ou_bot",
|
||||
"display_info": "<h>甲乙丙</h>\n一句话简介",
|
||||
"meta_data": map[string]interface{}{
|
||||
"tenant_id": "1", "enable_join_group": true, "chat_id": "oc_p2p", "is_agent": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSearchIntegrationRequestAndResponsePassThrough(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
stub := botSearchStub(botSearchURL+"?page_size=25", "cursor_out")
|
||||
registry.Register(stub)
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--query", "甲乙", "--chat-ids", "oc_a,oc_b", "--has-chatted",
|
||||
"--page-size", "25", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
|
||||
var requestBody map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &requestBody); err != nil {
|
||||
t.Fatalf("request body: %v", err)
|
||||
}
|
||||
if requestBody["query"] != "甲乙" {
|
||||
t.Fatalf("request query: got %v", requestBody["query"])
|
||||
}
|
||||
filter, ok := requestBody["filter"].(map[string]interface{})
|
||||
if !ok || filter["has_chatter"] != true || fmt.Sprint(filter["chat_ids"]) != "[oc_a oc_b]" {
|
||||
t.Fatalf("request filter: %#v", requestBody["filter"])
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Data searchBotResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("response JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if envelope.Data.Notice != "The query is too long and has been truncated to the first 50 characters for search." || !envelope.Data.HasMore {
|
||||
t.Fatalf("response pass-through: %+v", envelope.Data)
|
||||
}
|
||||
if len(envelope.Data.Bots) != 1 || envelope.Data.Bots[0].OpenID != "ou_bot" || envelope.Data.Bots[0].ChatID != "oc_p2p" {
|
||||
t.Fatalf("bots: %+v", envelope.Data.Bots)
|
||||
}
|
||||
registry.Verify(t)
|
||||
}
|
||||
|
||||
func TestBotSearchIntegrationNeverSurfacesPageToken(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
// The stub returns a token; the envelope must still not carry one, matching
|
||||
// +search-user, which decodes page_token and drops it.
|
||||
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "json", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("response JSON: %v", err)
|
||||
}
|
||||
data := envelope["data"].(map[string]interface{})
|
||||
if _, ok := data["page_token"]; ok {
|
||||
t.Fatalf("page_token must never be surfaced: %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSearchPrettyOutputAndPaginationHint(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "pretty", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
for _, column := range []string{"name", "description", "is_agent", "enable_join_group", "open_id"} {
|
||||
if !strings.Contains(stdout.String(), column) {
|
||||
t.Errorf("pretty output missing %q: %s", column, stdout.String())
|
||||
}
|
||||
}
|
||||
for _, genericField := range []string{"bots", "has_more", "notice", "tenant_id", "chat_id", "match_segments"} {
|
||||
if strings.Contains(stdout.String(), genericField) {
|
||||
t.Errorf("pretty output exposed %q: %s", genericField, stdout.String())
|
||||
}
|
||||
}
|
||||
// pretty stdout carries rows only, so stderr has to carry both the server
|
||||
// notice and the pagination hint.
|
||||
for _, want := range []string{
|
||||
"notice: The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"hint: more matches exist; narrow with --has-chatted or a more specific --query",
|
||||
} {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("pretty stderr missing %q: %q", want, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSearchTableUsesGenericFormatterLikeSearchUser(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", "table", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} {
|
||||
if !strings.Contains(stdout.String(), field) {
|
||||
t.Errorf("table output missing %q: %s", field, stdout.String())
|
||||
}
|
||||
}
|
||||
// table stdout carries rows only, so stderr has to carry both the server
|
||||
// notice and the pagination hint.
|
||||
for _, want := range []string{
|
||||
"notice: The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"hint: more matches exist; narrow with --has-chatted or a more specific --query",
|
||||
} {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("table stderr missing %q: %q", want, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The old name and assertion here pinned a bug: csv and ndjson were the two
|
||||
// formats that carried neither has_more in stdout nor a hint on stderr, so a
|
||||
// machine caller read a truncated result as the whole answer. stdout stays
|
||||
// data-only; the truncation signal belongs on stderr for every format whose
|
||||
// stdout has no envelope.
|
||||
func TestBotSearchCSVAndNDJSONCarryFullFieldsAndSignalTruncation(t *testing.T) {
|
||||
for _, format := range []string{"csv", "ndjson"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor_out"))
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "甲乙", "--format", format, "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
for _, field := range []string{"open_id", "tenant_id", "chat_id", "match_segments"} {
|
||||
if !strings.Contains(stdout.String(), field) {
|
||||
t.Errorf("%s output missing %q: %s", format, field, stdout.String())
|
||||
}
|
||||
}
|
||||
// stdout must stay data-only, so both the notice and the truncation
|
||||
// signal have to arrive on stderr.
|
||||
for _, want := range []string{"notice: The query is too long", "hint: more matches exist"} {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("%s dropped %q from stderr: %q", format, want, stderr.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(stdout.String(), "more matches exist") {
|
||||
t.Fatalf("%s stdout must stay data-only: %s", format, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSearchPrettyEmptyResult(t *testing.T) {
|
||||
factory, stdout, _, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: botSearchURL + "?page_size=20",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"items": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRun(t, ContactSearchBot, []string{"+search-bot", "--query", "none", "--format", "pretty", "--as", "user"}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No bots found.") {
|
||||
t.Fatalf("pretty output: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotSearchDryRunMirrorsRequest(t *testing.T) {
|
||||
factory, stdout, _, _ := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--query", "甲乙", "--chat-ids", "oc_a", "--has-chatted",
|
||||
"--page-size", "25", "--dry-run", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body botSearchAPIRequest `json:"body"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("dry-run JSON: %v", err)
|
||||
}
|
||||
if len(envelope.Data.API) != 1 {
|
||||
t.Fatalf("api calls: got %d, want 1", len(envelope.Data.API))
|
||||
}
|
||||
call := envelope.Data.API[0]
|
||||
if call.Method != "POST" || call.URL != botSearchURL || call.Params["page_size"] != float64(25) {
|
||||
t.Fatalf("dry-run call: %+v", call)
|
||||
}
|
||||
if call.Body.Query != "甲乙" || call.Body.Filter == nil || fmt.Sprint(call.Body.Filter.ChatIDs) != "[oc_a]" || !call.Body.Filter.HasChatter {
|
||||
t.Fatalf("dry-run body: %+v", call.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBotSearchAPIDataMarshalFailureTyped(t *testing.T) {
|
||||
_, err := decodeBotSearchAPIData(map[string]interface{}{"bad": func() {}})
|
||||
if err == nil {
|
||||
t.Fatal("expected marshal failure")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem: %+v, ok=%v", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the json envelope carries data.notice. If the other formats dropped it
|
||||
// silently, a caller would read a truncated or incomplete result as a complete
|
||||
// one, so every non-json format has to surface it on stderr instead.
|
||||
func TestBotSearchNoticeReachesCallerInEveryFormat(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(botSearchStub(botSearchURL+"?page_size=20", ""))
|
||||
if err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--query", "甲乙", "--format", format, "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), notice) {
|
||||
if format != "json" {
|
||||
t.Fatalf("%s should not carry the notice in stdout: %s", format, stdout.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.Contains(stderr.String(), notice) {
|
||||
t.Fatalf("%s dropped the notice entirely\nstdout:\n%s\nstderr:\n%s",
|
||||
format, stdout.String(), stderr.String())
|
||||
}
|
||||
// stdout stays pipe-clean: the notice must not be mixed into the rows.
|
||||
if format == "csv" && strings.Contains(stdout.String(), "notice") {
|
||||
t.Fatalf("csv stdout must stay data-only: %s", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// has_more is the server saying "this is not the whole answer". Only the json
|
||||
// envelope carries it, so every other format has to say so on stderr or a machine
|
||||
// caller silently treats a truncated result as complete.
|
||||
func TestBotSearchTruncationReachesCallerInEveryFormat(t *testing.T) {
|
||||
for _, format := range []string{"json", "ndjson", "csv", "table", "pretty"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
factory, stdout, stderr, registry := cmdutil.TestFactory(t, botSearchDefaultConfig())
|
||||
registry.Register(botSearchStub(botSearchURL+"?page_size=20", "cursor"))
|
||||
if err := mountAndRun(t, ContactSearchBot, []string{
|
||||
"+search-bot", "--query", "甲乙", "--format", format, "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if format == "json" {
|
||||
if !strings.Contains(stdout.String(), `"has_more": true`) {
|
||||
t.Fatalf("json must carry has_more in the envelope: %s", stdout.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "more matches exist") {
|
||||
t.Fatalf("%s left the caller unable to learn the result was truncated\nstdout:\n%s\nstderr:\n%s",
|
||||
format, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -550,6 +550,13 @@ func TestDecodeSearchUserAPIData_MarshalFailureTyped(t *testing.T) {
|
||||
// mountAndRun mounts the shortcut under a parent cobra command and runs it
|
||||
// with the given args. Mirrors the pattern used in other shortcut packages.
|
||||
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
return mountAndRunContext(t, context.Background(), s, args, f, stdout)
|
||||
}
|
||||
|
||||
// mountAndRunContext is mountAndRun with a caller-supplied context, so a test
|
||||
// can cancel the run the shortcut actually sees (runShortcut reads cmd.Context).
|
||||
func mountAndRunContext(t *testing.T, ctx context.Context, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "contact"}
|
||||
s.Mount(parent, f)
|
||||
@@ -559,7 +566,7 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
return parent.ExecuteContext(ctx)
|
||||
}
|
||||
|
||||
// searchUserStub returns a representative user search response with a notice.
|
||||
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
ContactSearchUser,
|
||||
ContactSearchBot,
|
||||
ContactGetUser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,26 +356,11 @@ func TestValidateUpdateV2Contract(t *testing.T) {
|
||||
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
|
||||
wantParam: "--pattern",
|
||||
},
|
||||
{
|
||||
name: "XML str_replace rejects multiline pattern",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"},
|
||||
wantParam: "--pattern",
|
||||
},
|
||||
{
|
||||
name: "block_delete without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_delete rejects empty ID",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_delete rejects duplicate ID",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_insert_after without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},
|
||||
|
||||
@@ -17,46 +17,6 @@ import (
|
||||
|
||||
// ── V2 (OpenAPI) tests ──
|
||||
|
||||
func TestStripTopLevelXMLTitles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single title",
|
||||
content: "<title>Content title</title><p>body</p>",
|
||||
want: "<p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "multiple titles",
|
||||
content: "<title>First</title>\n<p>body</p>\n<title>Second</title>",
|
||||
want: "<p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "nested title is preserved",
|
||||
content: "<callout><title>Nested</title></callout><p>body</p>",
|
||||
want: "<callout><title>Nested</title></callout><p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "malformed XML is preserved",
|
||||
content: "<title>Content title</title><p>A & B</p>",
|
||||
want: "<title>Content title</title><p>A & B</p>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := stripTopLevelXMLTitles(tt.content); got != tt.want {
|
||||
t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -18,7 +16,7 @@ import (
|
||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||
func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title; the CLI prepends it to --content as <title>...</title>. In XML mode, top-level <title> elements in --content are removed so this flag wins without duplicate-title warnings"},
|
||||
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
@@ -110,9 +108,6 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
|
||||
if title == "" {
|
||||
return content
|
||||
}
|
||||
if runtime.Str("doc-format") == "xml" {
|
||||
content = stripTopLevelXMLTitles(content)
|
||||
}
|
||||
|
||||
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
|
||||
if content == "" {
|
||||
@@ -121,62 +116,6 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
|
||||
return titleTag + "\n" + content
|
||||
}
|
||||
|
||||
type docContentRange struct {
|
||||
start int64
|
||||
end int64
|
||||
}
|
||||
|
||||
// stripTopLevelXMLTitles preserves the established --title-wins contract while
|
||||
// avoiding duplicate-title warnings from XML content. If the fragment is not
|
||||
// well-formed XML, it is left untouched for the service to diagnose.
|
||||
func stripTopLevelXMLTitles(content string) string {
|
||||
const wrapperStart = "<root>"
|
||||
wrapped := wrapperStart + content + "</root>"
|
||||
decoder := xml.NewDecoder(strings.NewReader(wrapped))
|
||||
wrapperLen := int64(len(wrapperStart))
|
||||
depth := 0
|
||||
activeStart := int64(-1)
|
||||
ranges := make([]docContentRange, 0, 1)
|
||||
|
||||
for {
|
||||
tokenStart := decoder.InputOffset()
|
||||
token, err := decoder.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
|
||||
switch value := token.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
|
||||
activeStart = tokenStart - wrapperLen
|
||||
}
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
|
||||
ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen})
|
||||
activeStart = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ranges) == 0 {
|
||||
return content
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
cursor := int64(0)
|
||||
for _, item := range ranges {
|
||||
result.WriteString(content[int(cursor):int(item.start)])
|
||||
cursor = item.end
|
||||
}
|
||||
result.WriteString(content[int(cursor):])
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
func escapeDocTitleText(title string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(title))
|
||||
|
||||
@@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag {
|
||||
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
|
||||
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
|
||||
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
}
|
||||
@@ -73,16 +73,10 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if pattern == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
|
||||
}
|
||||
if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern")
|
||||
}
|
||||
case "block_delete":
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
|
||||
}
|
||||
if err := validateBlockDeleteIDs(blockID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "block_insert_after":
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
|
||||
@@ -130,29 +124,6 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBlockDeleteIDs(raw string) error {
|
||||
seen := make(map[string]struct{})
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
blockID := strings.TrimSpace(part)
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id")
|
||||
}
|
||||
if _, ok := seen[blockID]; ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id")
|
||||
}
|
||||
seen[blockID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeBlockDeleteIDs(raw string) string {
|
||||
parts := strings.Split(raw, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
@@ -228,9 +199,6 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
|
||||
body["pattern"] = v
|
||||
}
|
||||
if blockID != "" {
|
||||
if cmd == "block_delete" {
|
||||
blockID = normalizeBlockDeleteIDs(blockID)
|
||||
}
|
||||
body["block_id"] = blockID
|
||||
}
|
||||
if v := runtime.Str("src-block-ids"); v != "" {
|
||||
|
||||
325
shortcuts/drive/drive_member_list.go
Normal file
325
shortcuts/drive/drive_member_list.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveMemberListSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
Fields string
|
||||
PermType string
|
||||
}
|
||||
|
||||
var driveMemberListTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
}
|
||||
|
||||
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
|
||||
var driveMemberListPermTypes = []string{"container", "single_page"}
|
||||
|
||||
var driveMemberListURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
|
||||
token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
return driveMemberListSpec{
|
||||
Token: token,
|
||||
Type: resourceType,
|
||||
Fields: fields,
|
||||
PermType: permType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
|
||||
explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
parsed, parseErr := url.Parse(raw)
|
||||
if parseErr != nil || parsed.Hostname() == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
|
||||
}
|
||||
ref, ok := parseDriveMemberListResourceURLPath(parsed.Path)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return ref.Token, ref.Type, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(driveMemberListTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(raw, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return raw, explicitType, nil
|
||||
}
|
||||
|
||||
func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) {
|
||||
for _, mapping := range driveMemberListURLPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListFields(raw string, changed bool) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
if changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
fields := make([]string, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
field := strings.ToLower(strings.TrimSpace(part))
|
||||
if field == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
if field == "*" {
|
||||
if len(parts) != 1 {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields")
|
||||
}
|
||||
return "*", nil
|
||||
}
|
||||
if !driveMemberListFieldAllowed(field) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for --fields, allowed: %s, *",
|
||||
strings.TrimSpace(part),
|
||||
strings.Join(driveMemberListFields, ", "),
|
||||
).WithParam("--fields")
|
||||
}
|
||||
if !seen[field] {
|
||||
fields = append(fields, field)
|
||||
seen[field] = true
|
||||
}
|
||||
}
|
||||
return strings.Join(fields, ","), nil
|
||||
}
|
||||
|
||||
func driveMemberListFieldAllowed(field string) bool {
|
||||
for _, allowed := range driveMemberListFields {
|
||||
if field == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) {
|
||||
permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resourceType != "wiki" && changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
|
||||
}
|
||||
return permType, nil
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if strings.EqualFold(value, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for %s, allowed: %s",
|
||||
value,
|
||||
flagName,
|
||||
strings.Join(allowed, ", "),
|
||||
).WithParam(flagName)
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) apiPath() string {
|
||||
return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token))
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) params() map[string]interface{} {
|
||||
params := map[string]interface{}{"type": s.Type}
|
||||
if s.Fields != "" {
|
||||
params["fields"] = s.Fields
|
||||
}
|
||||
if s.PermType != "" {
|
||||
params["perm_type"] = s.PermType
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// DriveMemberList lists collaborator/member permissions on a Drive resource.
|
||||
var DriveMemberList = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-list",
|
||||
Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.member:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"},
|
||||
{Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"},
|
||||
{Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders.",
|
||||
"--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.",
|
||||
"--perm-type only applies to wiki nodes.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List Drive collaborator/member permissions").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if items, ok := data["items"].([]interface{}); ok {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items))
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderDriveMemberListPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) {
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No Drive members found.")
|
||||
return
|
||||
}
|
||||
for i, raw := range items {
|
||||
member, _ := raw.(map[string]interface{})
|
||||
fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"]))
|
||||
fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"]))
|
||||
fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"]))
|
||||
if permType := driveMemberListValue(member["perm_type"]); permType != "-" {
|
||||
fmt.Fprintf(w, " perm_type: %s\n", permType)
|
||||
}
|
||||
if memberType := driveMemberListValue(member["type"]); memberType != "-" {
|
||||
fmt.Fprintf(w, " type: %s\n", memberType)
|
||||
}
|
||||
if name := driveMemberListValue(member["name"]); name != "-" {
|
||||
fmt.Fprintf(w, " name: %s\n", name)
|
||||
}
|
||||
if avatar := driveMemberListValue(member["avatar"]); avatar != "-" {
|
||||
fmt.Fprintf(w, " avatar: %s\n", avatar)
|
||||
}
|
||||
if label, ok := member["external_label"]; ok {
|
||||
fmt.Fprintf(w, " external_label: %v\n", label)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func driveMemberListValue(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
426
shortcuts/drive/drive_member_list_test.go
Normal file
426
shortcuts/drive/drive_member_list_test.go
Normal file
@@ -0,0 +1,426 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +member-list"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("fields", "", "")
|
||||
cmd.Flags().String("perm-type", "", "")
|
||||
for name, value := range map[string]string{
|
||||
"token": token,
|
||||
"type": docType,
|
||||
"fields": fields,
|
||||
"perm-type": permType,
|
||||
} {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "")
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok || spec.Type != tt.wantType {
|
||||
t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "folder",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "invalid fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "name,unknown",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "star mixed with fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "*,name",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "cannot be combined",
|
||||
},
|
||||
{
|
||||
name: "perm type rejected for non-wiki",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
permType: "single_page",
|
||||
wantParam: "--perm-type",
|
||||
wantMessage: "only applies when resource type is wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "default omits optional params",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
want: map[string]interface{}{"type": "docx"},
|
||||
},
|
||||
{
|
||||
name: "fields canonicalized and deduplicated",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "Name,avatar,name",
|
||||
want: map[string]interface{}{"type": "docx", "fields": "name,avatar"},
|
||||
},
|
||||
{
|
||||
name: "wiki accepts perm type",
|
||||
token: "wikTok",
|
||||
docType: "WIKI",
|
||||
fields: "*",
|
||||
permType: "SINGLE_PAGE",
|
||||
want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if got := spec.params(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("params = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "https://example.feishu.cn/drive/folder/fldTok",
|
||||
"--fields", "*",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.Data.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1", len(got.Data.API))
|
||||
}
|
||||
api := got.Data.API[0]
|
||||
if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" {
|
||||
t.Fatalf("api = %#v", api)
|
||||
}
|
||||
if api.Params["type"] != "folder" || api.Params["fields"] != "*" {
|
||||
t.Fatalf("params = %#v", api.Params)
|
||||
}
|
||||
if _, ok := api.Params["perm_type"]; ok {
|
||||
t.Fatalf("perm_type should be omitted for folder: %#v", api.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListExecutePreservesRawData(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
var capturedQuery string
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/doxTok/members",
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
"server_future": "preserved",
|
||||
"external_label": true,
|
||||
},
|
||||
},
|
||||
"server_top_level": "preserved",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--fields", "name,type,external_label",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(capturedQuery, "type=docx") ||
|
||||
!strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") {
|
||||
t.Fatalf("captured query = %q", capturedQuery)
|
||||
}
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["server_top_level"] != "preserved" {
|
||||
t.Fatalf("server_top_level = %#v", data["server_top_level"])
|
||||
}
|
||||
for _, key := range []string{"token", "type", "count"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %#v, want one item", data["items"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if item["server_future"] != "preserved" || item["external_label"] != true {
|
||||
t.Fatalf("item future fields not preserved: %#v", item)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Found 1 Drive member") {
|
||||
t.Fatalf("stderr = %q, want count log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListPrettyOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/wikTok/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"perm_type": "single_page",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "wikTok",
|
||||
"--type", "wiki",
|
||||
"--perm-type", "single_page",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
241
shortcuts/drive/drive_permission_get_setting.go
Normal file
241
shortcuts/drive/drive_permission_get_setting.go
Normal file
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type drivePermissionGetSettingSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) {
|
||||
rawToken := strings.TrimSpace(runtime.Str("token"))
|
||||
explicitType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
|
||||
|
||||
if rawToken == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token is required",
|
||||
).WithParam("--token")
|
||||
}
|
||||
|
||||
if explicitType != "" && !drivePermissionGetSettingTypeAllowed(explicitType) {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid --type %q: allowed values are %s",
|
||||
explicitType,
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if strings.Contains(rawToken, "://") {
|
||||
ref, ok := parseDrivePermissionGetSettingResourceURL(rawToken)
|
||||
if !ok {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
rawToken,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token (allowed: %s)",
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(rawToken, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: rawToken, Type: explicitType}, nil
|
||||
}
|
||||
|
||||
func parseDrivePermissionGetSettingResourceURL(rawURL string) (common.ResourceRef, bool) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
for _, mapping := range drivePermissionGetSettingURLPathToType {
|
||||
if !strings.HasPrefix(parsed.Path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := parsed.Path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingTypeAllowed(docType string) bool {
|
||||
for _, allowed := range drivePermissionGetSettingTypes {
|
||||
if docType == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string {
|
||||
if runtime != nil && runtime.Config != nil {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, s.Type, s.Token); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return common.BuildResourceURL("", s.Type, s.Token)
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) params() map[string]interface{} {
|
||||
return map[string]interface{}{"type": s.Type}
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) apiPath() string {
|
||||
return drivePermissionPublicV2Path(s.Token)
|
||||
}
|
||||
|
||||
func drivePermissionPublicV2Path(token string) string {
|
||||
return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token))
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
permissionPublic := common.GetMap(data, "permission_public")
|
||||
if permissionPublic == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"drive permission get response missing data.permission_public",
|
||||
)
|
||||
}
|
||||
return permissionPublic, nil
|
||||
}
|
||||
|
||||
// DrivePermissionGetSetting queries permission_public settings for a Drive
|
||||
// document, file, wiki node, or folder.
|
||||
var DrivePermissionGetSetting = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+permission-get-setting",
|
||||
Description: "Get public access, sharing, collaborator management, security, and comment permission settings",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.setting:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders. This shortcut reads the target's own permission settings; it does not recurse into child documents.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Get Drive permission settings").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
spec.apiPath(),
|
||||
spec.params(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
permissionPublic, err := drivePermissionGetSettingPermissionPublic(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissionPublicPretty, err := json.MarshalIndent(permissionPublic, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"encode drive permission settings for pretty output",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{"permission_public": permissionPublic}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Type: %s\n", spec.Type)
|
||||
fmt.Fprintf(w, "Token: %s\n", spec.Token)
|
||||
if url := spec.url(runtime); url != "" {
|
||||
fmt.Fprintf(w, "URL: %s\n", url)
|
||||
}
|
||||
fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
438
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
438
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
@@ -0,0 +1,438 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDrivePermissionGetSettingRuntime(t *testing.T, token, docType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +permission-get-setting"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
if token != "" {
|
||||
if err := cmd.Flags().Set("token", token); err != nil {
|
||||
t.Fatalf("set --token: %v", err)
|
||||
}
|
||||
}
|
||||
if docType != "" {
|
||||
if err := cmd.Flags().Set("type", docType); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "wiki URL",
|
||||
token: "https://example.feishu.cn/wiki/wikTok",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare file token",
|
||||
token: "boxTok",
|
||||
docType: "file",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok {
|
||||
t.Fatalf("Token = %q, want %q", spec.Token, tt.wantTok)
|
||||
}
|
||||
if spec.Type != tt.wantType {
|
||||
t.Fatalf("Type = %q, want %q", spec.Type, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "sheet",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid --type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr, ok := err.(*errs.ValidationError); ok {
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantURL string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: "fldTok",
|
||||
docType: "folder",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/doxTok/public",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantURL: "/open-apis/drive/v2/permissions/wikTok/public",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/boxTok/public",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/obTok/public",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/mndTok/public",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
dry := DrivePermissionGetSetting.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
out := string(data)
|
||||
for _, want := range []string{
|
||||
`"` + tt.wantURL + `"`,
|
||||
`"GET"`,
|
||||
`"type":"` + tt.wantType + `"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `"folder_token"`) {
|
||||
t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePreservesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
for _, key := range []string{"type", "token", "url"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want field omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
permissionPublic, _ := data["permission_public"].(map[string]interface{})
|
||||
if permissionPublic == nil {
|
||||
t.Fatalf("permission_public missing in output: %#v", data)
|
||||
}
|
||||
for key, want := range map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
} {
|
||||
if permissionPublic[key] != want {
|
||||
t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecuteRejectsMissingPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{"unexpected": "response"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid response error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", problem)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty on invalid response, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Permission settings:",
|
||||
`"link_share_entity": "closed"`,
|
||||
`"server_future_field": "preserved"`,
|
||||
} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.Scopes, []string{"docs:permission.setting:read"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.setting:read", DrivePermissionGetSetting.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DrivePermissionGetSetting.AuthTypes)
|
||||
}
|
||||
for _, flag := range DrivePermissionGetSetting.Flags {
|
||||
if flag.Name == "token" && !flag.Required {
|
||||
t.Fatal("--token must be declared required")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DriveMemberList,
|
||||
DrivePermissionGetSetting,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -39,6 +39,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+member-list",
|
||||
"+permission-get-setting",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -17,9 +17,10 @@ import (
|
||||
)
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
|
||||
// legacy synthetic-token prefix or a 28-character token whose interleaved
|
||||
// product/region marker is "OFL0X". The backend requires
|
||||
// "office_sheet_file" for those imported spreadsheets.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
@@ -27,22 +28,37 @@ const (
|
||||
localOfficePrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
// officePrefixes are the legacy synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry.
|
||||
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken, mapping either the
|
||||
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
|
||||
// "office_sheet_file".
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
func isOfficeSpreadsheet(spreadsheetToken string) bool {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(spreadsheetToken) != 28 {
|
||||
return false
|
||||
}
|
||||
// The five-character marker occupies positions 5, 10, 15, 20, and 25
|
||||
// (1-based) in the interleaved token.
|
||||
marker := []byte{
|
||||
spreadsheetToken[4],
|
||||
spreadsheetToken[9],
|
||||
spreadsheetToken[14],
|
||||
spreadsheetToken[19],
|
||||
spreadsheetToken[24],
|
||||
}
|
||||
return string(marker) == "OFL0X"
|
||||
}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
if isOfficeSpreadsheet(spreadsheetToken) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", "fake_office_abc123",
|
||||
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
|
||||
"--file", "img.png",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
@@ -117,10 +117,10 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
|
||||
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"office_sheet_file"`) {
|
||||
t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
|
||||
t.Fatalf("dry-run should include parent_type=office_sheet_file for interleaved OFL0X token, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `"sheet_image"`) {
|
||||
t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
|
||||
t.Fatalf("dry-run must not emit sheet_image for interleaved OFL0X token, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
|
||||
// "office" spreadsheet (token prefixed with "fake_office_") uploads with
|
||||
// "office" spreadsheet (token carrying the interleaved "OFL0X" marker) uploads with
|
||||
// parent_type=office_sheet_file instead of the native sheet_image.
|
||||
func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
@@ -259,7 +259,7 @@ func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
const officeToken = "fake_office_abc123"
|
||||
const officeToken = "aaaaOaaaaFaaaaLaaaa0aaaaXaaa"
|
||||
err := mountAndRunSheets(t, SheetMediaUpload, []string{
|
||||
"+media-upload",
|
||||
"--spreadsheet-token", officeToken,
|
||||
|
||||
@@ -53,9 +53,10 @@ func sheetsInputStatError(flag string, err error) error {
|
||||
}
|
||||
|
||||
// Drive media parent_type values for uploading an image into a spreadsheet.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
|
||||
// synthetic token prefixed with "fake_office_" (being renamed to
|
||||
// "local_office_") and the backend requires "office_sheet_file" instead.
|
||||
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
|
||||
// legacy synthetic-token prefix or a 28-character token whose interleaved
|
||||
// product/region marker is "OFL0X". The backend requires
|
||||
// "office_sheet_file" for those imported spreadsheets.
|
||||
const (
|
||||
sheetImageParentType = "sheet_image"
|
||||
officeSheetFileParentType = "office_sheet_file"
|
||||
@@ -63,21 +64,38 @@ const (
|
||||
localOfficePrefix = "local_office_"
|
||||
)
|
||||
|
||||
// officePrefixes are the synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
|
||||
// "local_office_"; accept either so image uploads keep working across the
|
||||
// rename.
|
||||
// officePrefixes are the legacy synthetic token prefixes an imported "office"
|
||||
// spreadsheet may carry.
|
||||
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
|
||||
|
||||
func isOfficeSpreadsheet(spreadsheetToken string) bool {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(spreadsheetToken) != 28 {
|
||||
return false
|
||||
}
|
||||
// The five-character marker occupies positions 5, 10, 15, 20, and 25
|
||||
// (1-based) in the interleaved token.
|
||||
marker := []byte{
|
||||
spreadsheetToken[4],
|
||||
spreadsheetToken[9],
|
||||
spreadsheetToken[14],
|
||||
spreadsheetToken[19],
|
||||
spreadsheetToken[24],
|
||||
}
|
||||
return string(marker) == "OFL0X"
|
||||
}
|
||||
|
||||
// sheetMediaParentType returns the drive media parent_type to use when
|
||||
// uploading an image whose parent_node is spreadsheetToken. It is the single
|
||||
// place that maps a spreadsheet token to its parent_type so every image-upload
|
||||
// entry point (and its dry-run preview) stays consistent.
|
||||
func sheetMediaParentType(spreadsheetToken string) string {
|
||||
for _, prefix := range officePrefixes {
|
||||
if strings.HasPrefix(spreadsheetToken, prefix) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
if isOfficeSpreadsheet(spreadsheetToken) {
|
||||
return officeSheetFileParentType
|
||||
}
|
||||
return sheetImageParentType
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ import (
|
||||
|
||||
// TestSheetMediaParentType pins the token→parent_type mapping that every
|
||||
// sheets image-upload entry point funnels through. Native spreadsheet tokens
|
||||
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
|
||||
// "local_office_" synthetic token and must upload with "office_sheet_file".
|
||||
// use "sheet_image"; imported "office" spreadsheets use either a legacy
|
||||
// prefix or the interleaved "OFL0X" marker and must upload with
|
||||
// "office_sheet_file".
|
||||
func TestSheetMediaParentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
@@ -40,6 +41,13 @@ func TestSheetMediaParentType(t *testing.T) {
|
||||
{"fake_office token, only the prefix", fakeOfficePrefix, officeSheetFileParentType},
|
||||
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
|
||||
{"local_office token, only the prefix", localOfficePrefix, officeSheetFileParentType},
|
||||
{"interleaved OFL0X office token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
|
||||
{"interleaved exlcn token", "abcdeefghxijkllmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved shtcn native token", "abcdsefghhijkltmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved pptcn token", "abcdpefghpijkltmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved wodcn token", "abcdwefghoijkldmnopcqrstnuv", sheetImageParentType},
|
||||
{"interleaved OFL0X marker with short length", "aaaaOaaaaFaaaaLaaaa0aaaaXaa", sheetImageParentType},
|
||||
{"interleaved OFL0X marker with long length", "aaaaOaaaaFaaaaLaaaa0aaaaXaaaa", sheetImageParentType},
|
||||
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
|
||||
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
|
||||
}
|
||||
@@ -57,7 +65,7 @@ func TestSheetMediaParentType(t *testing.T) {
|
||||
// to end (the Execute path the dry-run tests don't reach), asserting the
|
||||
// parent_type that actually goes out on the wire is derived from the token: a
|
||||
// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet
|
||||
// (fake_office_-prefixed token) as office_sheet_file.
|
||||
// (legacy prefix or interleaved OFL0X marker) as office_sheet_file.
|
||||
func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -67,6 +75,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
|
||||
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
|
||||
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
|
||||
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
|
||||
{"interleaved OFL0X imported spreadsheet", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -3,11 +3,24 @@
|
||||
|
||||
package slides
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
var presentationFlagAliases = []string{
|
||||
"presentation-id",
|
||||
"presentation-token",
|
||||
"token",
|
||||
"presentation_id",
|
||||
"xml-presentation-id",
|
||||
"url",
|
||||
}
|
||||
|
||||
// Shortcuts returns all slides shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
all := []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
@@ -18,4 +31,39 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesHistoryRevert,
|
||||
SlidesHistoryRevertStatus,
|
||||
}
|
||||
for i := range all {
|
||||
if hasPresentationFlag(all[i].Flags) {
|
||||
all[i].PostMount = withPresentationFlagAliases(all[i].PostMount)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func hasPresentationFlag(flags []common.Flag) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == "presentation" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// withPresentationFlagAliases accepts common agent-generated spellings for
|
||||
// --presentation without registering extra flags. The aliases therefore stay
|
||||
// out of help and completion while resolving to the canonical flag at parse
|
||||
// time, matching the zero-round-trip compatibility used by Sheets.
|
||||
func withPresentationFlagAliases(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
for _, alias := range presentationFlagAliases {
|
||||
if name == alias {
|
||||
return pflag.NormalizedName("presentation")
|
||||
}
|
||||
}
|
||||
return pflag.NormalizedName(name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
68
shortcuts/slides/shortcuts_alias_test.go
Normal file
68
shortcuts/slides/shortcuts_alias_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWithPresentationFlagAliases(t *testing.T) {
|
||||
for _, alias := range presentationFlagAliases {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
withPresentationFlagAliases(nil)(cmd)
|
||||
|
||||
if err := cmd.Flags().Parse([]string{"--" + alias, "presABC"}); err != nil {
|
||||
t.Fatalf("--%s should resolve to --presentation: %v", alias, err)
|
||||
}
|
||||
got, err := cmd.Flags().GetString("presentation")
|
||||
if err != nil {
|
||||
t.Fatalf("read --presentation: %v", err)
|
||||
}
|
||||
if got != "presABC" {
|
||||
t.Fatalf("--%s set --presentation to %q, want presABC", alias, got)
|
||||
}
|
||||
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--"+alias) {
|
||||
t.Fatalf("hidden compatibility alias --%s leaked into help:\n%s", alias, usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutsAttachPresentationFlagAliases(t *testing.T) {
|
||||
count := 0
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if !hasPresentationFlag(shortcut.Flags) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if shortcut.PostMount == nil {
|
||||
t.Errorf("%s has --presentation but no compatibility normalizer", shortcut.Command)
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
cmd.Flags().String("presentation", "", "presentation reference")
|
||||
shortcut.PostMount(cmd)
|
||||
if err := cmd.Flags().Parse([]string{"--token", "presABC"}); err != nil {
|
||||
t.Errorf("%s did not normalize --token: %v", shortcut.Command, err)
|
||||
continue
|
||||
}
|
||||
got, err := cmd.Flags().GetString("presentation")
|
||||
if err != nil {
|
||||
t.Errorf("%s could not read --presentation: %v", shortcut.Command, err)
|
||||
continue
|
||||
}
|
||||
if got != "presABC" {
|
||||
t.Errorf("%s normalized --token to %q, want presABC", shortcut.Command, got)
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatal("expected at least one slides shortcut with --presentation")
|
||||
}
|
||||
}
|
||||
@@ -37,15 +37,13 @@ var SlidesScreenshot = common.Shortcut{
|
||||
Command: "+screenshot",
|
||||
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
// advertise/preflight its scope. Let the API fail and let callers degrade.
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
|
||||
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides; max 10 pages per request)"},
|
||||
{Name: "slide-id", Type: "string_slice", Desc: "slide page identifier (repeat or comma-separated for multiple slides; max 10 pages per request)"},
|
||||
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
|
||||
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
|
||||
@@ -57,7 +55,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
if strings.TrimSpace(runtime.Str("content")) == "" {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
@@ -73,7 +71,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -98,7 +96,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
@@ -148,7 +146,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -200,7 +198,7 @@ func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
@@ -219,7 +217,7 @@ func executeRenderScreenshot(runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -17,23 +18,19 @@ import (
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
|
||||
t.Fatalf("user preflight scopes = %#v, want empty", got)
|
||||
base := []string{"slides:presentation:screenshot"}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); !reflect.DeepEqual(got, base) {
|
||||
t.Fatalf("user preflight scopes = %#v, want %#v", got, base)
|
||||
}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
|
||||
t.Fatalf("bot preflight scopes = %#v, want empty", got)
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); !reflect.DeepEqual(got, base) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want %#v", got, base)
|
||||
}
|
||||
|
||||
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
|
||||
want := []string{"wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, scope := range got {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
@@ -188,6 +185,139 @@ func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListBySlideIDCSV(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_id": "slide_1",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_2",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1,slide_2",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SlideIDs []string `json:"slide_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
|
||||
t.Fatalf("slide_ids = %#v, want [slide_1 slide_2]", body.SlideIDs)
|
||||
}
|
||||
|
||||
path1 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_1.png")
|
||||
if _, err := os.ReadFile(path1); err != nil {
|
||||
t.Fatalf("read first CSV slide screenshot: %v", err)
|
||||
}
|
||||
path2 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_2.png")
|
||||
if _, err := os.ReadFile(path2); err != nil {
|
||||
t.Fatalf("read second CSV slide screenshot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListBySlideIDCSVDeduplicatesAndTrims(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_id": "slide_1",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_2",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
// CSV with a duplicate and blank segments should normalize the same way
|
||||
// normalizeSlideIDs already does for repeated --slide-id flags.
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1, slide_2,slide_1,",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SlideIDs []string `json:"slide_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
|
||||
t.Fatalf("slide_ids = %#v, want deduplicated [slide_1 slide_2]", body.SlideIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListRejectsMoreThanTenSlideIDsCSV(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "s1,s2,s3,s4,s5,s6,s7,s8,s9,s10,s11",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %v, want typed validation error", err)
|
||||
}
|
||||
if problem.Hint != "request at most 10 pages at a time" {
|
||||
t.Fatalf("hint = %q, want max 10 pages guidance", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
@@ -390,6 +520,27 @@ func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderRejectsSlideNumberSelector(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
// Exercises the --slide-number-only side of the --content conflict check
|
||||
// (TestSlidesScreenshotRenderRejectsSlideSelectors above only covers the
|
||||
// --slide-id side of that same `||` condition).
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--slide-number", "1",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
|
||||
t.Fatalf("error = %v, want content/slide selector conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -100,6 +101,40 @@ func extractTaskGuid(input string) string {
|
||||
return extractTasklistGuid(input)
|
||||
}
|
||||
|
||||
var taskDisplayNumberPattern = regexp.MustCompile(`^t[0-9]+$`)
|
||||
|
||||
func parseTaskGUID(input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
invalid := func(format string, args ...interface{}) *errs.ValidationError {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...).
|
||||
WithParam("--task-id").
|
||||
WithHint("provide the Task OpenAPI GUID or a task applink containing guid=")
|
||||
}
|
||||
|
||||
if input == "" {
|
||||
return "", invalid("task ID is empty")
|
||||
}
|
||||
|
||||
lowerInput := strings.ToLower(input)
|
||||
if strings.HasPrefix(lowerInput, "http://") || strings.HasPrefix(lowerInput, "https://") {
|
||||
u, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return "", invalid("invalid task applink: %v", err).WithCause(err)
|
||||
}
|
||||
guid := strings.TrimSpace(u.Query().Get("guid"))
|
||||
if guid == "" {
|
||||
return "", invalid("task applink is missing a non-empty guid query parameter")
|
||||
}
|
||||
return guid, nil
|
||||
}
|
||||
|
||||
if taskDisplayNumberPattern.MatchString(input) {
|
||||
return "", invalid("task display number %q is not a Task OpenAPI GUID", input)
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := make(map[string]interface{})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user