mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
71 Commits
codex/insp
...
eval/skill
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbd729757b | ||
|
|
5a806febc8 | ||
|
|
aaced42956 | ||
|
|
a55bd76966 | ||
|
|
c15cb120a1 | ||
|
|
93e4fc7af0 | ||
|
|
feed8fb884 | ||
|
|
0fbfe68726 | ||
|
|
e1af7e3018 | ||
|
|
693e299589 | ||
|
|
69f335be7c | ||
|
|
d1a0926dd6 | ||
|
|
008bdda861 | ||
|
|
f1da8c274b | ||
|
|
842be3fdc5 | ||
|
|
1cd7a88597 | ||
|
|
7c64e63b9d | ||
|
|
8e60f01474 | ||
|
|
465c789f7c | ||
|
|
2a7e9c7d0d | ||
|
|
76ba6fad4f | ||
|
|
510545f1e5 | ||
|
|
c11cf3b716 | ||
|
|
ee2c93efeb | ||
|
|
33e459a4de | ||
|
|
5aeae2db65 | ||
|
|
9b39d10203 | ||
|
|
8572a58fda | ||
|
|
9bc66cc445 | ||
|
|
e53f9d999e | ||
|
|
ae35b35693 | ||
|
|
c2e617fc96 | ||
|
|
3f77eded9d | ||
|
|
e64610f6d2 | ||
|
|
dfa26c38f6 | ||
|
|
154ecdb90f | ||
|
|
483043c88b | ||
|
|
6d8dc402ac | ||
|
|
9f2e049858 | ||
|
|
2c703f2fce | ||
|
|
501bf539af | ||
|
|
8e667db534 | ||
|
|
e751a53f76 | ||
|
|
e794fd5925 | ||
|
|
077b5e7180 | ||
|
|
0d20a02050 | ||
|
|
7cc0b49603 | ||
|
|
6b48a39d55 | ||
|
|
b07be60068 | ||
|
|
31bc87a2cc | ||
|
|
7fdf55821b | ||
|
|
201e3e016f | ||
|
|
eed711bb11 | ||
|
|
4f4c0b59c9 | ||
|
|
2b4c6349a1 | ||
|
|
944cd55fc7 | ||
|
|
7229baae40 | ||
|
|
170565c57e | ||
|
|
03ea6e78b8 | ||
|
|
ed3fe9337f | ||
|
|
cc416a4de5 | ||
|
|
00d45f8fa2 | ||
|
|
0d847511d2 | ||
|
|
8f5504c51c | ||
|
|
d0a896ce91 | ||
|
|
99ceb2279c | ||
|
|
ec2ffebf47 | ||
|
|
ee5113f9d0 | ||
|
|
7cce7468d6 | ||
|
|
281cdbd37c | ||
|
|
add079ea1c |
30
.github/CODEOWNERS
vendored
Normal file
30
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
/internal/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
/skills/lark-approval/
|
||||
/skills/lark-apps/
|
||||
/skills/lark-attendance/
|
||||
/skills/lark-base/
|
||||
/skills/lark-calendar/
|
||||
/skills/lark-contact/
|
||||
/skills/lark-doc/
|
||||
/skills/lark-drive/
|
||||
/skills/lark-event/
|
||||
/skills/lark-im/
|
||||
/skills/lark-mail/
|
||||
/skills/lark-markdown/
|
||||
/skills/lark-minutes/
|
||||
/skills/lark-okr/
|
||||
/skills/lark-openapi-explorer/
|
||||
/skills/lark-shared/
|
||||
/skills/lark-sheets/
|
||||
/skills/lark-skill-maker/
|
||||
/skills/lark-slides/
|
||||
/skills/lark-task/
|
||||
/skills/lark-vc/
|
||||
/skills/lark-vc-agent/
|
||||
/skills/lark-whiteboard/
|
||||
/skills/lark-wiki/
|
||||
/skills/lark-workflow-meeting-summary/
|
||||
/skills/lark-workflow-standup-report/
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,8 @@ tests/mail/reports/
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
/notes/
|
||||
/minutes/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
|
||||
@@ -73,20 +73,20 @@ linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
|
||||
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper enforced on domains whose shared validation/save
|
||||
# helpers have migrated to typed final errors.
|
||||
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
|
||||
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
@@ -17,6 +17,7 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
|
||||
archives:
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
||||
28
AGENTS.md
28
AGENTS.md
@@ -11,7 +11,7 @@
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
@@ -75,7 +75,31 @@ The one rule to internalize: **every error message you write will be parsed by a
|
||||
|
||||
### Structured errors in commands
|
||||
|
||||
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
|
||||
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
|
||||
|
||||
Picking a constructor:
|
||||
|
||||
| Failure | Constructor |
|
||||
|---------|-------------|
|
||||
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
|
||||
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
|
||||
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
|
||||
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
|
||||
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
|
||||
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
|
||||
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
|
||||
|
||||
Signatures that are easy to guess wrong:
|
||||
|
||||
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
|
||||
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }` — `ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
|
||||
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
|
||||
|
||||
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
|
||||
|
||||
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
|
||||
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
|
||||
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
|
||||
|
||||
### stdout is data, stderr is everything else
|
||||
|
||||
|
||||
128
CHANGELOG.md
128
CHANGELOG.md
@@ -2,6 +2,129 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.53] - 2026-06-12
|
||||
|
||||
### Features
|
||||
|
||||
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
|
||||
- **auth**: Add `--json` flag support to auth subcommands (#1431)
|
||||
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
|
||||
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
|
||||
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Optimize whiteboard skill (#1371)
|
||||
- **skills**: Optimize okr skill (#1368)
|
||||
|
||||
## [v1.0.52] - 2026-06-11
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Per-resource subscription identity + Match hook (#1185)
|
||||
- **apps**: Emit typed error envelopes across the apps domain (#1288)
|
||||
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
|
||||
- **im**: Add `--chat-modes` filter to chat search (#1317)
|
||||
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
|
||||
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **apps**: Support git credential dry-run (#1390)
|
||||
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
|
||||
- **build**: Make `-race` flag arch-conditional to support riscv64
|
||||
|
||||
### Documentation
|
||||
|
||||
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
|
||||
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
|
||||
- **skills**: Optimize lark-drive skill routing (#1284)
|
||||
- **skills**: Expand cite user guidance and fix typos (#1394)
|
||||
|
||||
## [v1.0.51] - 2026-06-10
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Support multi dev modes (#1175)
|
||||
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
|
||||
- **base**: Configure initial base table schema (#1377)
|
||||
- **vc**: Add recording event support (#1369)
|
||||
- **minutes**: Replace words for transcript (#1372)
|
||||
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
|
||||
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
|
||||
- **slides**: Emit typed error envelopes across the slides domain (#1349)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
|
||||
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
|
||||
|
||||
## [v1.0.50] - 2026-06-09
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Emit typed error envelopes across the doc domain (#1346)
|
||||
- **event**: Emit typed error envelopes across the event domain (#1289)
|
||||
- **contact**: Emit typed error envelopes across the contact domain (#1287)
|
||||
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
|
||||
- **cli**: Adjust agent timeout hint output conditions (#1328)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
|
||||
- **slides**: Build create URL locally instead of drive metas call (#1329)
|
||||
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
|
||||
- **doc**: Document `<folder-manager>` resource block (#1168)
|
||||
- **drive**: Add drive comment location guidance (#1258)
|
||||
|
||||
## [v1.0.49] - 2026-06-08
|
||||
|
||||
### Features
|
||||
|
||||
- **events**: Add whiteboard event domain with per-board subscription (#1265)
|
||||
- **im**: Support feed group (#1102)
|
||||
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
|
||||
- **im**: Format feed group error handling (#1308)
|
||||
- **im**: Return typed error envelopes across the im domain (#1230)
|
||||
- **base**: Emit typed error envelopes across the base domain (#1248)
|
||||
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
|
||||
- **task**: Emit typed error envelopes across the task domain (#1231)
|
||||
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
|
||||
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
|
||||
- **markdown**: Harden create upload failures (#1325)
|
||||
- **drive**: Harden inspect shortcut failures (#1324)
|
||||
- **slides**: Add IconPark lookup for Lark slides (#1123)
|
||||
- **doc**: Remove docs v1 API (#1291)
|
||||
- **cli**: Add `skills` command to read embedded skill content (#1318)
|
||||
- **cli**: Fetch official skills index (#1301)
|
||||
- **shared**: Document relative-path-only file arguments (#1319)
|
||||
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
|
||||
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
|
||||
- **drive**: Use docs secure label read scope (#1281)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
|
||||
- **skills**: Tighten drive and markdown guardrails (#1326)
|
||||
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
|
||||
- **markdown**: Add markdown domain template (#1293)
|
||||
- **markdown**: Improve lark-markdown skill guidance (#1279)
|
||||
- **doc**: Improve lark-doc skill guidance (#1283)
|
||||
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
|
||||
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
|
||||
|
||||
## [v1.0.48] - 2026-06-04
|
||||
|
||||
### Features
|
||||
@@ -1026,6 +1149,11 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
|
||||
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
|
||||
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
|
||||
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
|
||||
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
|
||||
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
|
||||
9
Makefile
9
Makefile
@@ -8,6 +8,13 @@ DATE := $(shell date +%Y-%m-%d)
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
|
||||
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
|
||||
# over `go env GOARCH`, because command-line make variables are not visible to
|
||||
# $(shell ...).
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
@@ -34,7 +41,7 @@ fmt-check:
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test -race -gcflags="all=-N -l" -count=1 \
|
||||
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
|
||||
@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
|
||||
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
|
||||
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
|
||||
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
|
||||
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 |
|
||||
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
|
||||
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
|
||||
| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -91,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *CheckOptions
|
||||
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -109,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *LogoutOptions
|
||||
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -126,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -145,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -267,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--format", "pretty", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
if gotOpts.Format != "json" {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
type CheckOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Scope string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthCheck creates the auth check subcommand.
|
||||
@@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.MarkFlagRequired("scope")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// ListOptions holds all inputs for auth list.
|
||||
type ListOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthList creates the auth list subcommand.
|
||||
@@ -34,6 +35,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
@@ -44,6 +46,14 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
@@ -61,6 +71,14 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
@@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "No logged-in users") {
|
||||
t.Errorf("stderr = %q, want no-users hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,10 +296,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
// Step 2: Show user code and verification URL.
|
||||
// Both branches surface AgentTimeoutHint, but on different channels:
|
||||
// JSON mode embeds it as a structured field (so an agent that captures
|
||||
// stdout into a JSON parser sees it without stream-mixing surprises),
|
||||
// text mode prints to stderr (alongside the URL prompt).
|
||||
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
|
||||
// capture stdout into a JSON parser see it without stream-mixing surprises.
|
||||
// Text mode prints the hint to stderr only when running under a non-TTY
|
||||
// (i.e. piped / agent harness), since humans reading a terminal don't need
|
||||
// the agent-oriented instructions.
|
||||
if opts.JSON {
|
||||
data := map[string]interface{}{
|
||||
"event": "device_authorization",
|
||||
@@ -317,7 +318,9 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Poll for token
|
||||
@@ -404,10 +407,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
// Skip the stderr hint in JSON mode — the --no-wait call that issued the
|
||||
// device_code already returned the hint as a JSON field, and writing
|
||||
// text to stderr would pollute consumers that combine streams via 2>&1.
|
||||
if !opts.JSON {
|
||||
// Skip the stderr hint in JSON mode (the --no-wait call that issued
|
||||
// the device_code already surfaced it as a JSON field), and also skip it
|
||||
// when running on an interactive terminal — the agent-oriented
|
||||
// instructions only matter for piped / harness environments.
|
||||
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
|
||||
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"base", "contact", "docs", "markdown", "apps"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -214,6 +215,12 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
|
||||
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
|
||||
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains(t *testing.T) {
|
||||
projects := registry.ListFromMetaProjects()
|
||||
if len(projects) == 0 {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// LogoutOptions holds all inputs for auth logout.
|
||||
type LogoutOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthLogout creates the auth logout subcommand.
|
||||
@@ -34,6 +35,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
return authLogoutRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
@@ -44,25 +46,65 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
httpClient, httpErr := f.HttpClient()
|
||||
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
|
||||
|
||||
for _, user := range app.Users {
|
||||
if httpErr == nil && secretErr == nil {
|
||||
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
|
||||
revokeToken := token.RefreshToken
|
||||
tokenTypeHint := "refresh_token"
|
||||
if revokeToken == "" {
|
||||
revokeToken = token.AccessToken
|
||||
tokenTypeHint = "access_token"
|
||||
}
|
||||
if revokeToken != "" {
|
||||
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
|
||||
}
|
||||
}
|
||||
|
||||
app.Users = []core.AppUser{}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
356
cmd/auth/logout_test.go
Normal file
356
cmd/auth/logout_test.go
Normal file
@@ -0,0 +1,356 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
|
||||
t.Helper()
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "test-app",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: users,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != true {
|
||||
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
|
||||
}
|
||||
if _, hasReason := payload["reason"]; hasReason {
|
||||
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Logged out") {
|
||||
t.Errorf("stderr = %q, want success text", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "user-refresh-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_test" &&
|
||||
values.Get("client_secret") == "secret" &&
|
||||
values.Get("token") == "user-refresh-token" &&
|
||||
values.Get("token_type_hint") == "refresh_token"
|
||||
},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", got)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_test" &&
|
||||
values.Get("client_secret") == "secret" &&
|
||||
values.Get("token") == "user-access-token" &&
|
||||
values.Get("token_type_hint") == "access_token"
|
||||
},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if got := stderr.String(); !strings.Contains(got, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", got)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "cli_test",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "cli_test",
|
||||
UserOpenId: "ou_user",
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "user-refresh-token",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthRevoke,
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{"error": "server_error"},
|
||||
})
|
||||
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
gotErr := stderr.String()
|
||||
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
|
||||
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
|
||||
}
|
||||
if !strings.Contains(gotErr, "Logged out") {
|
||||
t.Fatalf("stderr = %q, want Logged out", gotErr)
|
||||
}
|
||||
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
|
||||
t.Fatalf("expected stored token removed, got %#v", got)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
|
||||
t.Fatalf("expected users cleared, got %#v", saved.Apps)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type ScopesOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
Format string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthScopes creates the auth scopes subcommand.
|
||||
@@ -30,6 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
Short: "Query scopes enabled for the app",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
if opts.JSON {
|
||||
opts.Format = "json"
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -38,6 +42,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Verify bool
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthStatus creates the auth status subcommand.
|
||||
@@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -33,15 +33,16 @@ const probeTimeout = 3 * time.Second
|
||||
//
|
||||
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||
// only when the server deterministically rejected the credentials — a
|
||||
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
|
||||
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
|
||||
// so the root dispatcher renders the canonical envelope and `config init`
|
||||
// exits non-zero — identical to how every other token-resolving command
|
||||
// reports the same bad credentials. Ambiguous failures (transport errors,
|
||||
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
|
||||
// errors and are swallowed (return nil), so valid configurations are never
|
||||
// disturbed by upstream noise. errs.IsTyped is the discriminator.
|
||||
// only when the unified Token Endpoint deterministically rejected the
|
||||
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
|
||||
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
|
||||
// typed error is propagated so the root dispatcher renders the canonical
|
||||
// envelope and `config init` exits non-zero — identical to how every other
|
||||
// token-resolving command reports the same bad credentials. Ambiguous
|
||||
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
|
||||
// timeouts) come back as raw untyped errors and are swallowed (return nil),
|
||||
// so valid configurations are never disturbed by upstream noise.
|
||||
// errs.IsTyped is the discriminator.
|
||||
//
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
|
||||
@@ -31,10 +31,10 @@ type fakeRT struct {
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
|
||||
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
|
||||
f.tatCalls++
|
||||
if f.tatHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
|
||||
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
|
||||
}
|
||||
return f.tatHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||
@@ -84,14 +84,15 @@ func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.B
|
||||
}
|
||||
|
||||
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
|
||||
// the expected upstream code. This is the same typed error every other
|
||||
// token-resolving command returns for the same bad credentials, and nothing is
|
||||
// written to stderr (the root dispatcher renders the envelope).
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
|
||||
// is the same typed error every other token-resolving command returns for the
|
||||
// same bad credentials, and nothing is written to stderr (the root dispatcher
|
||||
// renders the envelope). The numeric code is not asserted: the unified v3 Token
|
||||
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
|
||||
t.Fatal("expected *errs.ConfigError, got nil")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
@@ -103,9 +104,6 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCo
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != wantCode {
|
||||
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
@@ -123,11 +121,13 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
|
||||
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
|
||||
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
|
||||
// propagated. The probe endpoint must not be called when TAT fails.
|
||||
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
|
||||
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
@@ -137,28 +137,27 @@ func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
}
|
||||
assertConfigRejection(t, err, errBuf, 10003)
|
||||
assertConfigRejection(t, err, errBuf)
|
||||
}
|
||||
|
||||
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
|
||||
// the most common real-world rejection, propagated.
|
||||
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
|
||||
// unauthorized_client is treated as the same credential rejection, propagated.
|
||||
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
|
||||
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic rejection and propagates (typed).
|
||||
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
|
||||
// typed, so the probe still surfaces it rather than swallowing.
|
||||
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
|
||||
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
|
||||
// it rather than swallowing — but is not a credential (ConfigError) rejection.
|
||||
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
|
||||
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
@@ -38,7 +39,8 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
logger, err := bus.SetupBusLogger(eventsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
return errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"set up bus logger: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
tr := transport.New()
|
||||
@@ -58,7 +60,14 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
}()
|
||||
|
||||
return b.Run(ctx)
|
||||
if err := b.Run(ctx); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus daemon exited: %s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
45
cmd/event/bus_test.go
Normal file
45
cmd/event/bus_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// The hidden `event _bus` daemon command must exit with a typed file_io error
|
||||
// when its log directory cannot be created (the error is only visible in the
|
||||
// forked process's captured stderr / bus.log).
|
||||
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Block the events/ root with a regular file so MkdirAll fails.
|
||||
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdBus(f)
|
||||
cmd.SetArgs([]string{})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected logger setup error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryInternal, errs.SubtypeFileIO)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -64,8 +65,8 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
|
||||
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
|
||||
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
|
||||
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
|
||||
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
@@ -101,11 +102,10 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
|
||||
if o.jqExpr != "" {
|
||||
if err := output.ValidateJqExpression(o.jqExpr); err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
err.Error(),
|
||||
fmt.Sprintf("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
|
||||
WithParam("--jq").
|
||||
WithCause(err).
|
||||
WithHint("see `lark-cli event consume --help` EXAMPLES for common patterns, or `lark-cli event schema %s` for valid field paths", eventKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +184,9 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
|
||||
// Bounded runs already have --max-events/--timeout as their lifecycle control.
|
||||
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
|
||||
watchStdinEOF(os.Stdin, cancel, errOut)
|
||||
}
|
||||
|
||||
@@ -260,12 +261,12 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")),
|
||||
scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand),
|
||||
)
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"missing required scopes for EventKey %s (as %s): %s",
|
||||
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(pf.identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
@@ -300,23 +301,27 @@ func preflightEventTypes(pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
fmt.Sprintf("EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID)),
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"EventKey %s requires event types not subscribed in console: %s",
|
||||
pf.keyDef.Key, strings.Join(missing, ", ")).
|
||||
WithHint("subscribe these events and publish a new app version at: %s",
|
||||
consoleEventSubscriptionURL(pf.brand, pf.appID))
|
||||
}
|
||||
|
||||
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
|
||||
func sanitizeOutputDir(dir string) (string, error) {
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
return "", output.ErrValidation("%s; use a relative path like ./output instead", errOutputDirTilde)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s; use a relative path like ./output instead", errOutputDirTilde).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirTilde)
|
||||
}
|
||||
safe, err := validate.SafeOutputPath(dir)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%s %q: %s", errOutputDirUnsafe, dir, err)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: %s", errOutputDirUnsafe, dir, err).
|
||||
WithParam("--output-dir").
|
||||
WithCause(errOutputDirUnsafe)
|
||||
}
|
||||
return safe, nil
|
||||
}
|
||||
@@ -328,18 +333,21 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
|
||||
}
|
||||
result, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsBot, appID))
|
||||
if err != nil {
|
||||
return "", output.ErrAuth("resolve tenant access token: %s", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return "", err
|
||||
}
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"resolve tenant access token: %s", err).WithCause(err)
|
||||
}
|
||||
if result == nil || result.Token == "" {
|
||||
return "", output.ErrWithHint(
|
||||
output.ExitAuth, "auth",
|
||||
fmt.Sprintf("no tenant access token available for app %s", appID),
|
||||
"Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.",
|
||||
)
|
||||
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no tenant access token available for app %s", appID).
|
||||
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
|
||||
var (
|
||||
errInvalidParamFormat = errors.New("invalid --param format")
|
||||
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
|
||||
@@ -351,7 +359,10 @@ func parseParams(raw []string) (map[string]string, error) {
|
||||
for _, kv := range raw {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, output.ErrValidation("%s %q: expected key=value", errInvalidParamFormat, kv)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%s %q: expected key=value", errInvalidParamFormat, kv).
|
||||
WithParam("--param").
|
||||
WithCause(errInvalidParamFormat)
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
@@ -370,3 +381,8 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
|
||||
cancel()
|
||||
}()
|
||||
}
|
||||
|
||||
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
|
||||
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
|
||||
return !isTerminal && maxEvents <= 0 && timeout <= 0
|
||||
}
|
||||
|
||||
@@ -61,3 +61,70 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
|
||||
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldWatchStdinEOF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isTerminal bool
|
||||
maxEvents int
|
||||
timeout time.Duration
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
isTerminal: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal unbounded",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative max events is unbounded",
|
||||
maxEvents: -1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal negative timeout is unbounded",
|
||||
timeout: -1 * time.Second,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non terminal max events bounded",
|
||||
maxEvents: 1,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal timeout bounded",
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal both bounds positive",
|
||||
maxEvents: 1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded max events with negative timeout",
|
||||
maxEvents: 1,
|
||||
timeout: -1 * time.Second,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "non terminal bounded timeout with negative max events",
|
||||
maxEvents: -1,
|
||||
timeout: 10 * time.Minute,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
|
||||
if got != tt.want {
|
||||
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
@@ -73,6 +78,7 @@ func TestParseParams(t *testing.T) {
|
||||
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
||||
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--param")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -90,6 +96,77 @@ func TestParseParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// emptyTokenResolver resolves to a result that carries no token.
|
||||
type emptyTokenResolver struct{}
|
||||
|
||||
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{}, nil
|
||||
}
|
||||
|
||||
// failingTokenResolver fails outright with an untyped error.
|
||||
type failingTokenResolver struct{}
|
||||
|
||||
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, errors.New("backend unavailable")
|
||||
}
|
||||
|
||||
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
var malformed *credential.MalformedTokenResultError
|
||||
if !errors.As(err, &malformed) {
|
||||
t.Error("empty-token failure should preserve the credential-layer cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
|
||||
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("resolver failure should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
// assertInvalidArgumentParam verifies err is a typed validation error with
|
||||
// subtype invalid_argument naming the given flag in its param field.
|
||||
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
|
||||
t.Helper()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != param {
|
||||
t.Errorf("param = %q, want %q", ve.Param, param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutputDir(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -130,6 +207,7 @@ func TestSanitizeOutputDir(t *testing.T) {
|
||||
if !errors.Is(err, tc.wantSentry) {
|
||||
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
||||
}
|
||||
assertInvalidArgumentParam(t, err, "--output-dir")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -143,6 +143,79 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_ShowsSubColumn(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 2,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0},
|
||||
{PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB column header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "alice") {
|
||||
t.Errorf("missing alice suffix in SUB column: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "bob") {
|
||||
t.Errorf("missing bob suffix in SUB column: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writeStatusText(&buf, []appStatus{
|
||||
{
|
||||
AppID: "cli_RUNNINGXXXXXXXXX",
|
||||
State: stateRunning,
|
||||
PID: 1234,
|
||||
UptimeSec: 60,
|
||||
Active: 1,
|
||||
Consumers: []protocol.ConsumerInfo{
|
||||
{PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5},
|
||||
},
|
||||
},
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "SUB") {
|
||||
t.Errorf("missing SUB header: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "-") {
|
||||
t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStatusJSON_OrphanHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := writeStatusJSON(&buf, []appStatus{
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newPreflightCtx(appID string, brand core.LarkBrand, identity core.Identity, keyDef *eventlib.KeyDefinition, appVer *appmeta.AppVersion) *preflightCtx {
|
||||
@@ -89,19 +89,17 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "mail.user_mailbox.event.message_read_v1") {
|
||||
t.Errorf("error should name the missing event type, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitValidation {
|
||||
t.Errorf("ExitCode = %d, want ExitValidation (%d)", exit.Code, output.ExitValidation)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint")
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
||||
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
|
||||
if !strings.Contains(exit.Detail.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, exit.Detail.Hint)
|
||||
if !strings.Contains(p.Hint, wantURL) {
|
||||
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,17 +143,19 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "im:message.group_at_msg") {
|
||||
t.Errorf("error should name missing scope, got: %v", err)
|
||||
}
|
||||
var exit *output.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
t.Fatalf("expected output.ExitError, got %T: %v", err, err)
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if exit.Code != output.ExitAuth {
|
||||
t.Errorf("ExitCode = %d, want ExitAuth (%d)", exit.Code, output.ExitAuth)
|
||||
if permErr.Category != errs.CategoryAuthorization || permErr.Subtype != errs.SubtypeMissingScope {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", permErr.Category, permErr.Subtype,
|
||||
errs.CategoryAuthorization, errs.SubtypeMissingScope)
|
||||
}
|
||||
if exit.Detail == nil {
|
||||
t.Fatal("expected Detail with hint, got nil Detail")
|
||||
wantMissing := []string{"im:message.group_at_msg"}
|
||||
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != wantMissing[0] {
|
||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, wantMissing)
|
||||
}
|
||||
hint := exit.Detail.Hint
|
||||
hint := permErr.Hint
|
||||
wantSubstrings := []string{
|
||||
"https://open.feishu.cn/app/cli_x/auth?q=",
|
||||
"im:message.group_at_msg",
|
||||
|
||||
@@ -6,8 +6,8 @@ package event
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -26,7 +26,11 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
As: r.accessIdentity,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
// Non-JSON HTTP errors (gateway text/plain 404 etc.) skip OAPI envelope parsing.
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
@@ -36,11 +40,20 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
|
||||
if len(body) > maxBodyEcho {
|
||||
body = body[:maxBodyEcho] + "…(truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body).WithRetryable()
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s returned %d: %s", method, path, resp.StatusCode, body)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
|
||||
return json.RawMessage(resp.RawBody), apiErr
|
||||
|
||||
147
cmd/event/runtime_test.go
Normal file
147
cmd/event/runtime_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP calls.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
// stubRoundTripper intercepts every outgoing request with a canned response.
|
||||
type stubRoundTripper struct {
|
||||
respond func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
|
||||
|
||||
func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
return &consumeRuntime{
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func stubResponse(status int, contentType, body string) func(*http.Request) (*http.Response, error) {
|
||||
return func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{contentType}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requireCallAPIProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != category || p.Subtype != subtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, category, subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPError(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusNotFound, "text/plain", "gone")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
if !strings.Contains(err.Error(), "returned 404") {
|
||||
t.Errorf("error should echo the HTTP status, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_NonJSONHTTPErrorTruncatesLongBody(t *testing.T) {
|
||||
long := strings.Repeat("x", 300)
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusBadGateway, "text/html", long)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkServer)
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if !p.Retryable {
|
||||
t.Fatal("5xx non-JSON response should be marked retryable")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "…(truncated)") {
|
||||
t.Errorf("long body should be truncated in the message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_UnparsableJSONBody(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json", "{not json")})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
requireCallAPIProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_TransportFailure(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_EnvelopeErrorIsTyped(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":99991663,"msg":"app not found"}`)})
|
||||
_, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("envelope error should be typed via BuildAPIError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRuntimeCallAPI_Success(t *testing.T) {
|
||||
r := newTestConsumeRuntime(stubRoundTripper{respond: stubResponse(http.StatusOK, "application/json",
|
||||
`{"code":0,"data":{"ok":true}}`)})
|
||||
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/event/v1/connection", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"code":0`) {
|
||||
t.Errorf("raw body should pass through, got: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
@@ -39,12 +40,14 @@ func resolveSchemaJSON(def *eventlib.KeyDefinition) (json.RawMessage, []string,
|
||||
if len(def.Schema.FieldOverrides) > 0 {
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(base, &parsed); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"parse base schema for field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
orphans := schemas.ApplyFieldOverrides(parsed, def.Schema.FieldOverrides)
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"serialize schema with field overrides: %s", err).WithCause(err)
|
||||
}
|
||||
return out, orphans, nil
|
||||
}
|
||||
@@ -73,7 +76,7 @@ func renderSpec(s *eventlib.SchemaSpec) (json.RawMessage, error) {
|
||||
copy(buf, s.Raw)
|
||||
return buf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("schemaSpec has neither Type nor Raw")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "schemaSpec has neither Type nor Raw")
|
||||
}
|
||||
|
||||
func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -131,12 +134,16 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if len(def.Params) > 0 {
|
||||
fmt.Fprintf(out, "\nParameters:\n")
|
||||
w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n")
|
||||
fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n")
|
||||
for _, p := range def.Params {
|
||||
required := "no"
|
||||
if p.Required {
|
||||
required = "yes"
|
||||
}
|
||||
subKey := "no"
|
||||
if p.SubscriptionKey {
|
||||
subKey = "yes"
|
||||
}
|
||||
defaultVal := p.Default
|
||||
if defaultVal == "" {
|
||||
defaultVal = "-"
|
||||
@@ -145,7 +152,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc)
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
@@ -165,7 +172,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error {
|
||||
|
||||
resolved, _, err := resolveSchemaJSON(def)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "resolve schema: %v", err)
|
||||
return err
|
||||
}
|
||||
if resolved != nil {
|
||||
fmt.Fprintf(out, "\nOutput Schema:\n")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
@@ -95,6 +96,79 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{
|
||||
{Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"},
|
||||
{Name: "folders", Description: "filter only"},
|
||||
},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, false); err != nil {
|
||||
t.Fatalf("runSchema: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "SUB-KEY") {
|
||||
t.Errorf("missing SUB-KEY column header in:\n%s", out)
|
||||
}
|
||||
|
||||
// Find the mailbox row and verify "yes" is present
|
||||
var mailboxRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") {
|
||||
mailboxRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(mailboxRow, "yes") {
|
||||
t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow)
|
||||
}
|
||||
|
||||
// Find the folders row and verify "no" is present
|
||||
var foldersRow string
|
||||
for _, ln := range strings.Split(out, "\n") {
|
||||
if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") {
|
||||
foldersRow = ln
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(foldersRow, "no") {
|
||||
t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) {
|
||||
const syntheticKey = "test.evt_json"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
eventlib.RegisterKey(eventlib.KeyDefinition{
|
||||
Key: syntheticKey,
|
||||
EventType: syntheticKey,
|
||||
Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}},
|
||||
})
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
if err := runSchema(f, syntheticKey, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), `"subscription_key"`) {
|
||||
t.Errorf("JSON output missing subscription_key field: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `true`) {
|
||||
t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
const syntheticKey = "t.custom.overlay"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
@@ -129,3 +203,38 @@ func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) {
|
||||
t.Errorf("overlay format = %v, want open_id", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_EmptySpecIsTypedInternalError(t *testing.T) {
|
||||
_, err := renderSpec(&eventlib.SchemaSpec{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for spec with neither Type nor Raw")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSchemaJSON_InvalidBaseWithOverridesIsTypedInternalError(t *testing.T) {
|
||||
def := &eventlib.KeyDefinition{
|
||||
Key: "synthetic.invalid.base",
|
||||
Schema: eventlib.SchemaDef{
|
||||
Custom: &eventlib.SchemaSpec{Raw: json.RawMessage("{not json")},
|
||||
FieldOverrides: map[string]schemas.FieldMeta{"x": {}},
|
||||
},
|
||||
}
|
||||
_, _, err := resolveSchemaJSON(def)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unparsable base schema")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -242,12 +243,17 @@ func writeStatusText(out io.Writer, statuses []appStatus) {
|
||||
s.PID, (time.Duration(s.UptimeSec) * time.Second).String())
|
||||
fmt.Fprintf(out, " Active consumers: %d\n", s.Active)
|
||||
if len(s.Consumers) > 0 {
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"}
|
||||
headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"}
|
||||
rows := make([][]string, 0, len(s.Consumers))
|
||||
for _, c := range s.Consumers {
|
||||
subDisplay := "-"
|
||||
if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey {
|
||||
subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":")
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
fmt.Sprintf("pid=%d", c.PID),
|
||||
c.EventKey,
|
||||
subDisplay,
|
||||
fmt.Sprintf("%d", c.Received),
|
||||
fmt.Sprintf("%d", c.Dropped),
|
||||
})
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
@@ -64,9 +64,6 @@ func unknownEventKeyErr(key string) error {
|
||||
if guesses := suggestEventKeys(key); len(guesses) > 0 {
|
||||
msg += " — did you mean " + formatSuggestions(guesses) + "?"
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation, "validation",
|
||||
msg,
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithHint("Run 'lark-cli event list' to see available keys.")
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ const (
|
||||
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
|
||||
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
|
||||
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read)
|
||||
SubtypeExternalTool Subtype = "external_tool" // an external tool the CLI shells out to (git, npx) failed at runtime; the tool output is in the message
|
||||
SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
|
||||
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
|
||||
)
|
||||
|
||||
@@ -5,18 +5,19 @@ package minutes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
@@ -24,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
return func() error {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
35
events/vc/note_detail_retry_test.go
Normal file
35
events/vc/note_detail_retry_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// isLarkCode must match the API code on typed errs.* errors — the consume
|
||||
// runtime classifies OAPI failures via errclass.BuildAPIError, so the
|
||||
// not-found retry in fillVCNoteGeneratedDetails depends on this reading
|
||||
// Problem.Code rather than the legacy envelope shape.
|
||||
func TestIsLarkCode_MatchesTypedAPIErrorCode(t *testing.T) {
|
||||
typedNotFound := errs.NewAPIError(errs.SubtypeNotFound, "note not ready").
|
||||
WithCode(vcNoteDetailNotFoundCode)
|
||||
if !isLarkCode(typedNotFound, vcNoteDetailNotFoundCode) {
|
||||
t.Fatal("typed API error carrying the not-found code must match (retry path)")
|
||||
}
|
||||
if isLarkCode(typedNotFound, 99999) {
|
||||
t.Error("a different expected code must not match")
|
||||
}
|
||||
|
||||
otherTyped := errs.NewAPIError(errs.SubtypeServerError, "boom").WithCode(500)
|
||||
if isLarkCode(otherTyped, vcNoteDetailNotFoundCode) {
|
||||
t.Error("typed error with another code must not match")
|
||||
}
|
||||
|
||||
if isLarkCode(errors.New("plain failure"), vcNoteDetailNotFoundCode) {
|
||||
t.Error("untyped error must not match")
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,11 @@ package vc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
@@ -148,9 +147,8 @@ func fillVCNoteGeneratedDetails(ctx context.Context, rt event.APIClient, out *VC
|
||||
}
|
||||
|
||||
func isLarkCode(err error, code int) bool {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.Detail != nil {
|
||||
return exitErr.Detail.Code == code
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
return p.Code == code
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,18 +5,19 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 5 * time.Second
|
||||
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) {
|
||||
func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
body := map[string]string{"event_type": eventType}
|
||||
@@ -24,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
return func() error {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
84
events/vc/recording_ended.go
Normal file
84
events/vc/recording_ended.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1.
|
||||
type VCRecordingEndedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.recording.recording_ended_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
EventTime string `json:"event_time,omitempty" desc:"Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||
}
|
||||
|
||||
type recordingEndedEnvelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event recordingEndedEvent `json:"event"`
|
||||
}
|
||||
|
||||
type recordingEndedEvent struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
envelope, ok := parseRecordingEndedEnvelope(raw)
|
||||
if !ok {
|
||||
return raw.Payload, nil
|
||||
}
|
||||
if !isRecordingEndedBeanEvent(envelope) {
|
||||
return nil, nil
|
||||
}
|
||||
out := &VCRecordingEndedOutput{
|
||||
Type: recordingEndedEventType(envelope, raw),
|
||||
EventID: envelope.Header.EventID,
|
||||
EventTime: recordingEndedEventTime(envelope.Header.CreateTime),
|
||||
UniqueKey: envelope.Event.UniqueKey,
|
||||
Source: envelope.Event.Source,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) {
|
||||
var envelope recordingEndedEnvelope
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &envelope, true
|
||||
}
|
||||
|
||||
func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool {
|
||||
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||
}
|
||||
|
||||
func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string {
|
||||
if envelope != nil && envelope.Header.EventType != "" {
|
||||
return envelope.Header.EventType
|
||||
}
|
||||
return raw.EventType
|
||||
}
|
||||
|
||||
func recordingEndedEventTime(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
84
events/vc/recording_started.go
Normal file
84
events/vc/recording_started.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1.
|
||||
type VCRecordingStartedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.recording.recording_started_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
EventTime string `json:"event_time,omitempty" desc:"Recording start time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||
}
|
||||
|
||||
type recordingStartedEnvelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event recordingStartedEvent `json:"event"`
|
||||
}
|
||||
|
||||
type recordingStartedEvent struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
envelope, ok := parseRecordingStartedEnvelope(raw)
|
||||
if !ok {
|
||||
return raw.Payload, nil
|
||||
}
|
||||
if !isRecordingStartedBeanEvent(envelope) {
|
||||
return nil, nil
|
||||
}
|
||||
out := &VCRecordingStartedOutput{
|
||||
Type: recordingStartedEventType(envelope, raw),
|
||||
EventID: envelope.Header.EventID,
|
||||
EventTime: recordingStartedEventTime(envelope.Header.CreateTime),
|
||||
UniqueKey: envelope.Event.UniqueKey,
|
||||
Source: envelope.Event.Source,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) {
|
||||
var envelope recordingStartedEnvelope
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &envelope, true
|
||||
}
|
||||
|
||||
func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool {
|
||||
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||
}
|
||||
|
||||
func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string {
|
||||
if envelope != nil && envelope.Header.EventType != "" {
|
||||
return envelope.Header.EventType
|
||||
}
|
||||
return raw.EventType
|
||||
}
|
||||
|
||||
func recordingStartedEventTime(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
468
events/vc/recording_test.go
Normal file
468
events/vc/recording_test.go
Normal file
@@ -0,0 +1,468 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestVCKeys_RecordingEventsRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
eventType string
|
||||
}{
|
||||
{eventTypeRecordingStarted},
|
||||
{eventTypeRecordingTranscriptGenerated},
|
||||
{eventTypeRecordingEnded},
|
||||
} {
|
||||
t.Run(tc.eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(tc.eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:recording:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
|
||||
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
|
||||
}
|
||||
if !strings.Contains(def.Description, "recording_bean") {
|
||||
t.Errorf("Description should document recording_bean source, got %q", def.Description)
|
||||
}
|
||||
if !strings.Contains(def.Description, "connected to Feishu software") {
|
||||
t.Errorf("Description should document Feishu software connection requirement, got %q", def.Description)
|
||||
}
|
||||
if strings.Contains(def.Description, "future") || strings.Contains(def.Description, "software_recording") {
|
||||
t.Errorf("Description should not mention future sources, got %q", def.Description)
|
||||
}
|
||||
if tc.eventType == eventTypeRecordingEnded && (strings.Contains(def.Description, "object_type") || strings.Contains(def.Description, "object_id")) {
|
||||
t.Errorf("ended Description should not document object metadata, got %q", def.Description)
|
||||
}
|
||||
wantSchemaType := reflect.TypeOf(VCRecordingStartedOutput{})
|
||||
switch tc.eventType {
|
||||
case eventTypeRecordingTranscriptGenerated:
|
||||
wantSchemaType = reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})
|
||||
case eventTypeRecordingEnded:
|
||||
wantSchemaType = reflect.TypeOf(VCRecordingEndedOutput{})
|
||||
}
|
||||
if def.Schema.Custom.Type != wantSchemaType {
|
||||
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, wantSchemaType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingStarted(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
out := runRecordingProcess[VCRecordingStartedOutput](t, eventTypeRecordingStarted, processVCRecordingStarted, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_start_001",
|
||||
"event_type": "vc.recording.recording_started_v1",
|
||||
"create_time": "1761782400000"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean"
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeRecordingStarted {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_rec_start_001" || out.EventTime != recordingTestEventTime(1761782400000) {
|
||||
t.Errorf("EventID/EventTime = %q/%q", out.EventID, out.EventTime)
|
||||
}
|
||||
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingTranscriptGenerated(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
got := runRecordingProcessRaw(t, eventTypeRecordingTranscriptGenerated, processVCRecordingTranscriptGenerated, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_transcript_001",
|
||||
"event_type": "vc.recording.recording_transcript_generated_v1",
|
||||
"create_time": "1761782400100"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean",
|
||||
"transcript_items": [
|
||||
{
|
||||
"speaker": {
|
||||
"id": {
|
||||
"open_id": "ou_0f8bf7acdf2ae69553ecbdbfbbd10a53",
|
||||
"union_id": "on_bc03f16d781bff4178a5d11e48eb1867",
|
||||
"user_id": null
|
||||
},
|
||||
"user_type": 100,
|
||||
"user_role": 1,
|
||||
"user_name": "Alice"
|
||||
},
|
||||
"text": "hello world",
|
||||
"language": "en_us",
|
||||
"start_time_ms": "1761782399000",
|
||||
"end_time_ms": "1761782400000",
|
||||
"sentence_id": "987654321"
|
||||
},
|
||||
{
|
||||
"speaker": {
|
||||
"user_name": "Bob"
|
||||
},
|
||||
"text": "second sentence",
|
||||
"language": "en_us",
|
||||
"start_time_ms": "1761782401000",
|
||||
"end_time_ms": "1761782402000",
|
||||
"sentence_id": "987654322"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
if got == nil {
|
||||
t.Fatal("Process output is nil")
|
||||
}
|
||||
var out VCRecordingTranscriptGeneratedOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
|
||||
if out.Type != eventTypeRecordingTranscriptGenerated {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||
}
|
||||
if out.EventTime != recordingTestEventTime(1761782400100) {
|
||||
t.Errorf("EventTime = %q", out.EventTime)
|
||||
}
|
||||
if len(out.TranscriptItems) != 2 {
|
||||
t.Fatalf("TranscriptItems len = %d, want 2", len(out.TranscriptItems))
|
||||
}
|
||||
item := out.TranscriptItems[0]
|
||||
if item.SpeakerName != "Alice" || item.Text != "hello world" {
|
||||
t.Errorf("Transcript speaker/text = %q/%q", item.SpeakerName, item.Text)
|
||||
}
|
||||
if item.StartTime != recordingTestEventTime(1761782399000) || item.EndTime != recordingTestEventTime(1761782400000) {
|
||||
t.Errorf("Transcript timing = %q/%q", item.StartTime, item.EndTime)
|
||||
}
|
||||
if item.SentenceID != "987654321" {
|
||||
t.Errorf("SentenceID = %q, want 987654321", item.SentenceID)
|
||||
}
|
||||
if out.TranscriptItems[1].SpeakerName != "Bob" || out.TranscriptItems[1].SentenceID != "987654322" {
|
||||
t.Errorf("second transcript item = %+v", out.TranscriptItems[1])
|
||||
}
|
||||
itemJSON, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal transcript item: %v", err)
|
||||
}
|
||||
var itemFields map[string]any
|
||||
if err := json.Unmarshal(itemJSON, &itemFields); err != nil {
|
||||
t.Fatalf("unmarshal transcript item JSON: %v", err)
|
||||
}
|
||||
wantItemFields := map[string]bool{
|
||||
"speaker_name": true,
|
||||
"text": true,
|
||||
"start_time": true,
|
||||
"end_time": true,
|
||||
"sentence_id": true,
|
||||
}
|
||||
for gotField := range itemFields {
|
||||
if !wantItemFields[gotField] {
|
||||
t.Errorf("Transcript item should not contain field %q, got %s", gotField, string(itemJSON))
|
||||
}
|
||||
}
|
||||
for wantField := range wantItemFields {
|
||||
if _, ok := itemFields[wantField]; !ok {
|
||||
t.Errorf("Transcript item missing field %q, got %s", wantField, string(itemJSON))
|
||||
}
|
||||
}
|
||||
for _, unexpected := range []string{
|
||||
`"seq_id"`,
|
||||
`"speaker"`,
|
||||
`"user_open_id"`,
|
||||
`"user_type"`,
|
||||
`"user_role"`,
|
||||
`"language"`,
|
||||
`"start_time_ms"`,
|
||||
`"end_time_ms"`,
|
||||
`"sequence_id"`,
|
||||
`"transcript_item"`,
|
||||
} {
|
||||
if strings.Contains(string(got), unexpected) {
|
||||
t.Errorf("Transcript output should not contain %s, got %s", unexpected, string(got))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(got), `"sentence_id":"987654321"`) {
|
||||
t.Errorf("Transcript output should contain sentence_id, got %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingEnded(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
out := runRecordingProcess[VCRecordingEndedOutput](t, eventTypeRecordingEnded, processVCRecordingEnded, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_end_001",
|
||||
"event_type": "vc.recording.recording_ended_v1",
|
||||
"create_time": "1761782400200"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean",
|
||||
"object_type": "minutes",
|
||||
"object_id": "minute_token_001"
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeRecordingEnded {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
}
|
||||
if out.UniqueKey != "recording_001" || out.Source != "recording_bean" {
|
||||
t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source)
|
||||
}
|
||||
if out.EventTime != recordingTestEventTime(1761782400200) {
|
||||
t.Errorf("EventTime = %q", out.EventTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecordingEnded_DropsObjectMetadata(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
got := runRecordingProcessRaw(t, eventTypeRecordingEnded, processVCRecordingEnded, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_end_001",
|
||||
"event_type": "vc.recording.recording_ended_v1",
|
||||
"create_time": "1761782400200"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean",
|
||||
"object_type": "minutes",
|
||||
"object_id": "minute_token_001"
|
||||
}
|
||||
}`)
|
||||
|
||||
if strings.Contains(string(got), "object_type") || strings.Contains(string(got), "object_id") {
|
||||
t.Fatalf("ended output should drop object metadata, got %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecording_DropsTimestampField(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
got := runRecordingProcessRaw(t, eventTypeRecordingStarted, processVCRecordingStarted, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_rec_start_001",
|
||||
"event_type": "vc.recording.recording_started_v1",
|
||||
"create_time": "1761782400000"
|
||||
},
|
||||
"event": {
|
||||
"unique_key": "recording_001",
|
||||
"source": "recording_bean"
|
||||
}
|
||||
}`)
|
||||
|
||||
if strings.Contains(string(got), `"timestamp"`) {
|
||||
t.Fatalf("recording output should use event_time instead of timestamp, got %s", string(got))
|
||||
}
|
||||
if !strings.Contains(string(got), `"event_time":"`+recordingTestEventTime(1761782400000)+`"`) {
|
||||
t.Fatalf("recording output should include ISO 8601 event_time, got %s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
payload string
|
||||
}{
|
||||
{
|
||||
name: "started",
|
||||
eventType: eventTypeRecordingStarted,
|
||||
process: processVCRecordingStarted,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "ev_rec_start_001", "event_type": "vc.recording.recording_started_v1"},
|
||||
"event": {"unique_key": "recording_001", "source": "software_recording"}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "transcript",
|
||||
eventType: eventTypeRecordingTranscriptGenerated,
|
||||
process: processVCRecordingTranscriptGenerated,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "ev_rec_transcript_001", "event_type": "vc.recording.recording_transcript_generated_v1"},
|
||||
"event": {"unique_key": "recording_001", "source": "software_recording", "transcript_items": []}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "ended",
|
||||
eventType: eventTypeRecordingEnded,
|
||||
process: processVCRecordingEnded,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {"event_id": "ev_rec_end_001", "event_type": "vc.recording.recording_ended_v1"},
|
||||
"event": {"unique_key": "recording_001", "source": "software_recording"}
|
||||
}`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := runRecordingProcessRaw(t, tc.eventType, tc.process, tc.payload)
|
||||
if got != nil {
|
||||
t.Fatalf("non-recording_bean event should be filtered, got %s", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{name: "started", eventType: eventTypeRecordingStarted, process: processVCRecordingStarted},
|
||||
{name: "transcript", eventType: eventTypeRecordingTranscriptGenerated, process: processVCRecordingTranscriptGenerated},
|
||||
{name: "ended", eventType: eventTypeRecordingEnded, process: processVCRecordingEnded},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: tc.eventType,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
eventType string
|
||||
}{
|
||||
{eventTypeRecordingStarted},
|
||||
{eventTypeRecordingTranscriptGenerated},
|
||||
{eventTypeRecordingEnded},
|
||||
} {
|
||||
t.Run(tc.eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(tc.eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||
}
|
||||
|
||||
type call struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}
|
||||
var calls []call
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
calls = append(calls, call{method: method, path: path, body: body})
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup must not be nil")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||
}
|
||||
if calls[0].method != "POST" || calls[0].path != pathRecordingSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, tc.eventType)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathRecordingUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, tc.eventType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runRecordingProcess[T any](t *testing.T, eventType string, process event.ProcessFunc, payload string) T {
|
||||
t.Helper()
|
||||
got := runRecordingProcessRaw(t, eventType, process, payload)
|
||||
if got == nil {
|
||||
t.Fatal("Process output is nil")
|
||||
}
|
||||
var out T
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runRecordingProcessRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventType,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func recordingTestEventTime(millis int64) string {
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
163
events/vc/recording_transcript_generated.go
Normal file
163
events/vc/recording_transcript_generated.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events.
|
||||
type VCRecordingTranscriptItemOutput struct {
|
||||
SpeakerName string `json:"speaker_name,omitempty" desc:"Speaker display name"`
|
||||
Text string `json:"text,omitempty" desc:"Transcript text"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Transcript item start time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
EndTime string `json:"end_time,omitempty" desc:"Transcript item end time in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
SentenceID string `json:"sentence_id,omitempty" desc:"Transcript sentence ID"`
|
||||
}
|
||||
|
||||
// VCRecordingTranscriptGeneratedOutput is the flattened shape for vc.recording.recording_transcript_generated_v1.
|
||||
type VCRecordingTranscriptGeneratedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.recording.recording_transcript_generated_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
EventTime string `json:"event_time,omitempty" desc:"Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone"`
|
||||
UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"`
|
||||
Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"`
|
||||
TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedEnvelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event recordingTranscriptGeneratedEvent `json:"event"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedEvent struct {
|
||||
UniqueKey string `json:"unique_key"`
|
||||
Source string `json:"source"`
|
||||
TranscriptItems []recordingTranscriptGeneratedItemIn `json:"transcript_items"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedItemIn struct {
|
||||
Speaker *recordingTranscriptGeneratedSpeakerIn `json:"speaker"`
|
||||
Text string `json:"text"`
|
||||
StartTimeMs recordingTranscriptGeneratedString `json:"start_time_ms"`
|
||||
EndTimeMs recordingTranscriptGeneratedString `json:"end_time_ms"`
|
||||
SentenceID string `json:"sentence_id"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedSpeakerIn struct {
|
||||
UserName string `json:"user_name"`
|
||||
}
|
||||
|
||||
type recordingTranscriptGeneratedString string
|
||||
|
||||
func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw)
|
||||
if !ok {
|
||||
return raw.Payload, nil
|
||||
}
|
||||
if !isRecordingTranscriptGeneratedBeanEvent(envelope) {
|
||||
return nil, nil
|
||||
}
|
||||
out := &VCRecordingTranscriptGeneratedOutput{
|
||||
Type: recordingTranscriptGeneratedEventType(envelope, raw),
|
||||
EventID: envelope.Header.EventID,
|
||||
EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime),
|
||||
UniqueKey: envelope.Event.UniqueKey,
|
||||
Source: envelope.Event.Source,
|
||||
TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems),
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) {
|
||||
var envelope recordingTranscriptGeneratedEnvelope
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &envelope, true
|
||||
}
|
||||
|
||||
func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool {
|
||||
return envelope != nil && envelope.Event.Source == "recording_bean"
|
||||
}
|
||||
|
||||
func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string {
|
||||
if envelope != nil && envelope.Header.EventType != "" {
|
||||
return envelope.Header.EventType
|
||||
}
|
||||
return raw.EventType
|
||||
}
|
||||
|
||||
func recordingTranscriptGeneratedEventTime(raw string) string {
|
||||
return recordingTranscriptGeneratedMillisToLocalRFC3339(raw)
|
||||
}
|
||||
|
||||
func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(millis).Local().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]VCRecordingTranscriptItemOutput, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, recordingTranscriptItem(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordingTranscriptItemOutput {
|
||||
return VCRecordingTranscriptItemOutput{
|
||||
SpeakerName: recordingSpeakerName(item.Speaker),
|
||||
Text: item.Text,
|
||||
StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()),
|
||||
EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()),
|
||||
SentenceID: item.SentenceID,
|
||||
}
|
||||
}
|
||||
|
||||
func recordingSpeakerName(speaker *recordingTranscriptGeneratedSpeakerIn) string {
|
||||
if speaker == nil {
|
||||
return ""
|
||||
}
|
||||
return speaker.UserName
|
||||
}
|
||||
|
||||
func (s *recordingTranscriptGeneratedString) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
*s = recordingTranscriptGeneratedString(str)
|
||||
return nil
|
||||
}
|
||||
var num json.Number
|
||||
if err := json.Unmarshal(data, &num); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = recordingTranscriptGeneratedString(num.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s recordingTranscriptGeneratedString) String() string {
|
||||
return string(s)
|
||||
}
|
||||
@@ -11,13 +11,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
||||
eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1"
|
||||
eventTypeRecordingEnded = "vc.recording.recording_ended_v1"
|
||||
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
||||
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||
pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription"
|
||||
pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription"
|
||||
pathRecordingSubscribe = "/open-apis/vc/v1/recordings/subscription"
|
||||
pathRecordingUnsubscribe = "/open-apis/vc/v1/recordings/unsubscription"
|
||||
|
||||
pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s"
|
||||
)
|
||||
@@ -57,5 +62,53 @@ func Keys() []event.KeyDefinition {
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeNoteGenerated},
|
||||
},
|
||||
{
|
||||
Key: eventTypeRecordingStarted,
|
||||
DisplayName: "Recording started",
|
||||
Description: "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.",
|
||||
EventType: eventTypeRecordingStarted,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})},
|
||||
},
|
||||
Process: processVCRecordingStarted,
|
||||
PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||
Scopes: []string{"vc:recording:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingStarted},
|
||||
},
|
||||
{
|
||||
Key: eventTypeRecordingTranscriptGenerated,
|
||||
DisplayName: "Recording transcript generated",
|
||||
Description: "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.",
|
||||
EventType: eventTypeRecordingTranscriptGenerated,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})},
|
||||
},
|
||||
Process: processVCRecordingTranscriptGenerated,
|
||||
PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||
Scopes: []string{"vc:recording:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingTranscriptGenerated},
|
||||
},
|
||||
{
|
||||
Key: eventTypeRecordingEnded,
|
||||
DisplayName: "Recording ended",
|
||||
Description: "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.",
|
||||
EventType: eventTypeRecordingEnded,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})},
|
||||
},
|
||||
Process: processVCRecordingEnded,
|
||||
PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe),
|
||||
Scopes: []string{"vc:recording:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingEnded},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
@@ -21,14 +22,18 @@ const cleanupTimeout = 5 * time.Second
|
||||
//
|
||||
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
|
||||
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
|
||||
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
whiteboardID := params["whiteboard_id"]
|
||||
if whiteboardID == "" {
|
||||
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"param whiteboard_id is required for %s", eventType).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param whiteboard_id=<id>; run `lark-cli event schema %s` for details", eventType)
|
||||
}
|
||||
encoded := validate.EncodePathSegment(whiteboardID)
|
||||
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
|
||||
@@ -39,10 +44,13 @@ func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, ev
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() {
|
||||
return func() error {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
defer cancel()
|
||||
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
|
||||
if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
@@ -58,6 +59,16 @@ func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "whiteboard_id") {
|
||||
t.Fatalf("error should mention whiteboard_id, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("missing whiteboard_id should carry a hint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
|
||||
@@ -70,6 +81,9 @@ func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when runtime client is nil")
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryInternal {
|
||||
t.Errorf("nil-runtime invariant should be a typed internal error, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
|
||||
|
||||
@@ -47,6 +47,7 @@ type DeviceFlowResult struct {
|
||||
// OAuthEndpoints contains the OAuth endpoint URLs.
|
||||
type OAuthEndpoints struct {
|
||||
DeviceAuthorization string
|
||||
Revoke string
|
||||
Token string
|
||||
}
|
||||
|
||||
@@ -55,6 +56,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
return OAuthEndpoints{
|
||||
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
|
||||
Revoke: ep.Accounts + PathOAuthRevoke,
|
||||
Token: ep.Open + PathOAuthTokenV2,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
|
||||
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
|
||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||
}
|
||||
if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" {
|
||||
t.Errorf("Revoke = %q", ep.Revoke)
|
||||
}
|
||||
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
@@ -42,6 +45,9 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) {
|
||||
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
|
||||
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
|
||||
}
|
||||
if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" {
|
||||
t.Errorf("Revoke = %q", ep.Revoke)
|
||||
}
|
||||
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("Token = %q", ep.Token)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ package auth
|
||||
const (
|
||||
// PathDeviceAuthorization is the endpoint for device authorization.
|
||||
PathDeviceAuthorization = "/oauth/v1/device_authorization"
|
||||
// PathOAuthRevoke is the endpoint for revoking an OAuth token.
|
||||
PathOAuthRevoke = "/oauth/v1/revoke"
|
||||
// PathAppRegistration is the endpoint for application registration.
|
||||
PathAppRegistration = "/oauth/v1/app/registration"
|
||||
// PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2).
|
||||
|
||||
131
internal/auth/revoke.go
Normal file
131
internal/auth/revoke.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// RevokeToken revokes a previously issued OAuth token.
|
||||
func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error {
|
||||
endpoints := ResolveOAuthEndpoints(brand)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", appId)
|
||||
form.Set("client_secret", appSecret)
|
||||
form.Set("token", token)
|
||||
if tokenTypeHint != "" {
|
||||
form.Set("token_type_hint", tokenTypeHint)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return revokeHTTPStatusError(resp.StatusCode, body)
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if code := getInt(data, "code", 0); code != 0 {
|
||||
msg := getStr(data, "msg")
|
||||
if msg == "" {
|
||||
msg = getStr(data, "message")
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "unknown error"
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg).
|
||||
WithCode(code).
|
||||
WithCause(errors.New(msg))
|
||||
}
|
||||
|
||||
if errStr := getStr(data, "error"); errStr != "" {
|
||||
msg := getStr(data, "error_description")
|
||||
if msg == "" {
|
||||
msg = errStr
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg).
|
||||
WithCause(errors.New(msg))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeHTTPStatusError(status int, body []byte) error {
|
||||
msg := formatOAuthErrorBody(body)
|
||||
cause := errors.New(strings.TrimSpace(string(body)))
|
||||
if strings.TrimSpace(string(body)) == "" {
|
||||
cause = errors.New(msg)
|
||||
}
|
||||
if status >= http.StatusInternalServerError {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg).
|
||||
WithCode(status).
|
||||
WithRetryable().
|
||||
WithCause(cause)
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if status == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg).
|
||||
WithCode(status).
|
||||
WithCause(cause)
|
||||
}
|
||||
|
||||
func formatOAuthErrorBody(body []byte) string {
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return "empty response"
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if msg := getStr(data, "error_description"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "msg"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "message"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := getStr(data, "error"); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
207
internal/auth/revoke_test.go
Normal file
207
internal/auth/revoke_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
type revokeRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return fn(req)
|
||||
}
|
||||
|
||||
type errReadCloser struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r errReadCloser) Read(_ []byte) (int, error) {
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
func (r errReadCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRevokeToken_PostsExpectedForm(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{"code": 0},
|
||||
BodyFilter: func(body []byte) bool {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return values.Get("client_id") == "cli_a" &&
|
||||
values.Get("client_secret") == "secret_b" &&
|
||||
values.Get("token") == "user-access-token" &&
|
||||
values.Get("token_type_hint") == "access_token"
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err != nil {
|
||||
t.Fatalf("RevokeToken() error = %v", err)
|
||||
}
|
||||
if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) {
|
||||
sentinel := errors.New("transport down")
|
||||
httpClient := &http.Client{
|
||||
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, sentinel
|
||||
}),
|
||||
}
|
||||
|
||||
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem = %#v, want network/transport", p)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsHTTPError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{"error": "invalid_token"},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 400 {
|
||||
t.Fatalf("problem = %#v, want api error with HTTP 400", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid_token") {
|
||||
t.Fatalf("expected invalid_token error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{
|
||||
"code": 12345,
|
||||
"msg": "invalid revoke state",
|
||||
},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Code != 12345 {
|
||||
t.Fatalf("problem = %#v, want api error with code 12345", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid revoke state") {
|
||||
t.Fatalf("expected oauth error message, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
t.Cleanup(func() { reg.Verify(t) })
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: PathOAuthRevoke,
|
||||
Body: map[string]interface{}{
|
||||
"error": "invalid_token",
|
||||
"error_description": "token already expired",
|
||||
},
|
||||
})
|
||||
|
||||
err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Fatalf("problem = %#v, want api error", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token already expired") {
|
||||
t.Fatalf("expected oauth error_description, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) {
|
||||
sentinel := errors.New("read failed")
|
||||
httpClient := &http.Client{
|
||||
Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: errReadCloser{err: sentinel},
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T", err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", p)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "token revoke read error") {
|
||||
t.Fatalf("expected read error message, got %v", err)
|
||||
}
|
||||
if _, ok := err.(*errs.InternalError); !ok {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", err)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ func ParseBrand(value string) LarkBrand {
|
||||
return BrandFeishu
|
||||
}
|
||||
|
||||
// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts
|
||||
// domain. It serves every grant type (client_credentials for TAT,
|
||||
// authorization_code / device_code / refresh_token for UAT) and replaces the
|
||||
// legacy per-token endpoints (e.g. /open-apis/auth/v3/tenant_access_token/internal).
|
||||
const OAuthTokenV3Path = "/oauth/v3/token"
|
||||
|
||||
// Endpoints holds resolved endpoint URLs for different Lark services.
|
||||
type Endpoints struct {
|
||||
Open string // e.g. "https://open.feishu.cn"
|
||||
|
||||
@@ -42,6 +42,11 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
|
||||
if ep.Open != "https://open.feishu.cn" {
|
||||
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open)
|
||||
}
|
||||
// The unified OAuth v3 Token Endpoint mints TAT on the accounts domain;
|
||||
// pin the default-brand host so a stray non-production domain revert is caught.
|
||||
if ep.Accounts != "https://accounts.feishu.cn" {
|
||||
t.Errorf("Accounts = %q, want accounts.feishu.cn for empty brand", ep.Accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpenBaseURL(t *testing.T) {
|
||||
|
||||
@@ -19,33 +19,44 @@ import (
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
)
|
||||
|
||||
// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
|
||||
// canonical typed error. The TAT mint endpoint reports invalid credentials
|
||||
// with two distinct codes:
|
||||
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
|
||||
// unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint
|
||||
// reports failures using the OAuth 2.0 model — an `error` string plus an
|
||||
// optional numeric `code` — instead of the legacy `{code, msg}` shape.
|
||||
//
|
||||
// - 10003: bad app_id format or non-existent app_id ("invalid param")
|
||||
// - 10014: invalid app_secret ("app secret invalid")
|
||||
//
|
||||
// Both surface as CategoryConfig/InvalidClient from the user's perspective —
|
||||
// the configured credentials cannot mint a tenant access token. 10014 is
|
||||
// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
|
||||
// 10003 is NOT globally mapped because in other Lark endpoints it carries
|
||||
// unrelated semantics (e.g. task API uses 10003 for permission denied), so
|
||||
// the override stays local to this TAT call site instead of leaking into the
|
||||
// shared codemeta table.
|
||||
func classifyTATResponseCode(code int, msg, brand, appID string) error {
|
||||
if code == 10003 {
|
||||
// invalid_client / unauthorized_client mean the configured app_id/app_secret
|
||||
// cannot mint a token; from the user's perspective that is the same actionable
|
||||
// CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced.
|
||||
// Every other deterministic error falls through to BuildAPIError, which still
|
||||
// yields a typed error so probe callers (errs.IsTyped) surface it rather than
|
||||
// swallowing it. Transient/server-side failures (5xx / server_error) are
|
||||
// filtered out by FetchTAT before this is called, so they stay untyped.
|
||||
func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error {
|
||||
msg := errDesc
|
||||
if msg == "" {
|
||||
msg = oauthErr
|
||||
}
|
||||
switch oauthErr {
|
||||
case "invalid_client", "unauthorized_client":
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
|
||||
WithCode(code).
|
||||
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
|
||||
}
|
||||
return errclass.BuildAPIError(map[string]any{
|
||||
if err := errclass.BuildAPIError(map[string]any{
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
}, errclass.ClassifyContext{
|
||||
Brand: brand,
|
||||
AppID: appID,
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// BuildAPIError returns nil for code 0 (Feishu's success convention), but this
|
||||
// function is only reached once FetchTAT has ruled out success — a non-credential
|
||||
// OAuth error (e.g. invalid_scope) can arrive with code 0 and is still a
|
||||
// deterministic rejection. Back it with a typed APIError so callers never receive
|
||||
// the ("", nil) "empty token, no error" pair.
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg).WithCode(code)
|
||||
}
|
||||
|
||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||
@@ -146,8 +157,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er
|
||||
return &TokenResult{Token: token, Scopes: scopes}, nil
|
||||
}
|
||||
|
||||
// resolveTAT resolves a tenant access token. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used.
|
||||
// resolveTAT resolves a tenant access token. The result is cached after the first
|
||||
// call via sync.Once — only the context from the first call is used.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
||||
|
||||
@@ -19,18 +19,16 @@ func TestDefaultAccountProvider_Implements(t *testing.T) {
|
||||
var _ DefaultAccountResolver = &DefaultAccountProvider{}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT
|
||||
// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient.
|
||||
// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint,
|
||||
// which from the user's perspective is the same actionable failure as 10014
|
||||
// ("app secret invalid") — both mean the configured credentials cannot mint a
|
||||
// tenant access token. The global codemeta intentionally does not map 10003
|
||||
// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task
|
||||
// API uses it for permission denied), so the override is local to this site.
|
||||
func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient pins that the
|
||||
// unified Token Endpoint's OAuth2 invalid_client error surfaces as
|
||||
// CategoryConfig/InvalidClient — the configured app_id/app_secret cannot mint a
|
||||
// tenant access token, the same actionable failure the legacy 10003/10014 codes
|
||||
// produced. The numeric code is intentionally not asserted: the v3 endpoint may
|
||||
// return invalid_client with no Lark code (code defaults to 0).
|
||||
func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "invalid_client", "client authentication failed", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code=10003")
|
||||
t.Fatal("expected non-nil error for invalid_client")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
@@ -42,22 +40,16 @@ func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
if cfgErr.Hint == "" {
|
||||
t.Error("Hint must be non-empty so the user gets a recovery action")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still
|
||||
// goes through the global BuildAPIError path (codemeta entry) so the override
|
||||
// for 10003 does not regress the existing mapping.
|
||||
func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
|
||||
err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code=10014")
|
||||
}
|
||||
// TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient pins that
|
||||
// unauthorized_client is treated as the same credential failure as
|
||||
// invalid_client.
|
||||
func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "unauthorized_client", "client not authorized", "feishu", "cli_app_x")
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
@@ -65,21 +57,38 @@ func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10014 {
|
||||
t.Errorf("Code = %d, want 10014", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside
|
||||
// the credential set fall through to the generic BuildAPIError fallback
|
||||
// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional.
|
||||
func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) {
|
||||
err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x")
|
||||
// TestClassifyTATResponseCode_OtherErrorFallsThrough pins that OAuth errors
|
||||
// outside the credential set fall through to the generic BuildAPIError fallback
|
||||
// — still typed, but not a ConfigError. The mapping is narrow and intentional.
|
||||
func TestClassifyTATResponseCode_OtherErrorFallsThrough(t *testing.T) {
|
||||
err := classifyTATResponseCode(20068, "invalid_scope", "unauthorized scope", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for unmapped code")
|
||||
t.Fatal("expected non-nil error for invalid_scope")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
|
||||
t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped pins the code-0
|
||||
// backstop: a non-credential OAuth error (e.g. invalid_scope) that arrives with no
|
||||
// numeric code (code 0) must still produce a non-nil typed error. BuildAPIError
|
||||
// returns nil for code 0 (Feishu's success convention); without the backstop,
|
||||
// FetchTAT would surface this deterministic rejection as ("", nil) — an empty token
|
||||
// with no error.
|
||||
func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
|
||||
err := classifyTATResponseCode(0, "invalid_scope", "the requested scope is not granted", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code-0 invalid_scope (must not be swallowed as success)")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,46 +4,47 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
|
||||
// given credentials. It does not read configuration or keychain, so callers
|
||||
// that already hold plaintext credentials (e.g. the post-`config init` probe)
|
||||
// can validate them without a second keychain round-trip.
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
|
||||
// unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the
|
||||
// client_credentials grant with client_secret_post authentication. It does not
|
||||
// read configuration or keychain, so callers that already hold plaintext
|
||||
// credentials (e.g. the post-`config init` probe) can validate them without a
|
||||
// second keychain round-trip.
|
||||
//
|
||||
// A non-zero TAT response code means the server inspected the payload and
|
||||
// rejected the credentials; FetchTAT returns the canonical typed error from
|
||||
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
|
||||
// every token-resolving command) produces, so callers see one consistent
|
||||
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
|
||||
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
|
||||
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
|
||||
// credential rejection apart from upstream/transport noise.
|
||||
// A deterministic client-side rejection (e.g. invalid_client) returns the
|
||||
// canonical typed error from classifyTATResponseCode — the SAME classification
|
||||
// doResolveTAT (and thus every token-resolving command) produces, so callers
|
||||
// see one consistent envelope. Transport failures, unreadable/unparseable
|
||||
// bodies, and transient server-side failures (5xx / server_error) are returned
|
||||
// raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a
|
||||
// deterministic credential rejection apart from upstream/transport noise.
|
||||
//
|
||||
// The caller owns the context timeout.
|
||||
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
endpoint := ep.Accounts + core.OAuthTokenV3Path
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": appID,
|
||||
"app_secret": appSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "client_credentials")
|
||||
form.Set("client_id", appID)
|
||||
form.Set("client_secret", appSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -51,20 +52,51 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read TAT response: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
Code int `json:"code"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
// An unparseable body is ambiguous (covers non-JSON error pages and
|
||||
// truncated payloads); stay untyped so probe callers treat it as noise.
|
||||
return "", fmt.Errorf("failed to parse TAT response (HTTP %d): %w", resp.StatusCode, err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
|
||||
|
||||
if result.Code == 0 && result.AccessToken != "" {
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
return result.TenantAccessToken, nil
|
||||
|
||||
// Transient/server-side failures stay untyped so probe callers stay silent and
|
||||
// retryers can back off; only deterministic client rejections are typed. Covers
|
||||
// 5xx, HTTP 429 rate-limit, and the OAuth transient error strings (server_error,
|
||||
// temporarily_unavailable, slow_down) — matching the legacy "non-2xx is noise"
|
||||
// behavior so a rate-limited probe is not surfaced as a hard credential error.
|
||||
if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests ||
|
||||
result.Error == "server_error" || result.Error == "temporarily_unavailable" ||
|
||||
result.Error == "slow_down" {
|
||||
return "", fmt.Errorf("TAT endpoint transient failure (HTTP %d, code=%d, error=%q): %s",
|
||||
resp.StatusCode, result.Code, result.Error, result.ErrorDescription)
|
||||
}
|
||||
|
||||
// A 2xx with neither token nor error is a malformed success — ambiguous, untyped.
|
||||
if result.Code == 0 && result.Error == "" {
|
||||
return "", fmt.Errorf("TAT response missing access_token (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Prefer the OAuth error_description; fall back to the legacy Lark `msg` so a
|
||||
// gateway-level {code, msg} response (carrying no OAuth fields) still yields a
|
||||
// non-empty typed message instead of a bare "API error: [code]".
|
||||
desc := result.ErrorDescription
|
||||
if desc == "" {
|
||||
desc = result.Msg
|
||||
}
|
||||
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
func TestFetchTAT_Success(t *testing.T) {
|
||||
rt := &stubRoundTripper{
|
||||
respCode: 200,
|
||||
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
|
||||
respBody: `{"code":0,"access_token":"t-abc","token_type":"Bearer","expires_in":7200}`,
|
||||
}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
@@ -55,24 +55,33 @@ func TestFetchTAT_Success(t *testing.T) {
|
||||
if token != "t-abc" {
|
||||
t.Errorf("token = %q, want t-abc", token)
|
||||
}
|
||||
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
|
||||
if rt.gotReq.URL.String() != "https://accounts.feishu.cn/oauth/v3/token" {
|
||||
t.Errorf("url = %s", rt.gotReq.URL.String())
|
||||
}
|
||||
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
|
||||
t.Errorf("request body missing credentials: %s", rt.gotBody)
|
||||
if ct := rt.gotReq.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
|
||||
t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct)
|
||||
}
|
||||
// client_secret_post: grant_type + client_id + client_secret in the form body.
|
||||
for _, want := range []string{"grant_type=client_credentials", "client_id=cli_app", "client_secret=secret_x"} {
|
||||
if !strings.Contains(rt.gotBody, want) {
|
||||
t.Errorf("request body missing %q: %s", want, rt.gotBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
|
||||
// invalid_client (wrong app_id/app_secret on the client_credentials grant) is a
|
||||
// deterministic client-side rejection that FetchTAT routes to
|
||||
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
|
||||
// typed error doResolveTAT (and thus every token-resolving command) returns.
|
||||
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
|
||||
// The v3 endpoint reports it as HTTP 400 with the OAuth2 error body (wrong
|
||||
// secret → code 20002, unknown app → code 20048).
|
||||
func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 10003")
|
||||
t.Fatal("expected error for invalid_client")
|
||||
}
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
@@ -87,52 +96,115 @@ func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 10014 ("app secret invalid") — the most common real-world rejection (real
|
||||
// app_id + wrong secret) — is globally mapped in codemeta to
|
||||
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
|
||||
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
|
||||
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic server-side rejection, so it
|
||||
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
|
||||
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
|
||||
// caller still surfaces it rather than silently swallowing.
|
||||
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
|
||||
// Any other deterministic client-side OAuth error (e.g. invalid_scope) still
|
||||
// yields a typed error (errs.IsTyped) via BuildAPIError — so a probe caller
|
||||
// surfaces it rather than silently swallowing it — but is NOT classified as a
|
||||
// credential (invalid_client) problem.
|
||||
func TestFetchTAT_OtherClientError_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 99999")
|
||||
t.Fatal("expected error for invalid_scope")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Errorf("invalid_scope must not be classified as ConfigError/InvalidClient, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
|
||||
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
|
||||
// silent.
|
||||
// A deterministic OAuth error that arrives WITHOUT a numeric code (code defaults to
|
||||
// 0) must still surface as a non-nil typed error — never the ("", nil) success pair.
|
||||
// Guards the code-0 backstop in classifyTATResponseCode: BuildAPIError returns nil
|
||||
// for code 0, which would otherwise swallow this rejection into an empty-token success.
|
||||
func TestFetchTAT_OtherClientError_CodeZero_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_scope","error_description":"the requested scope is not granted"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
tok, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code-0 invalid_scope (must not return empty token + nil error)")
|
||||
}
|
||||
if tok != "" {
|
||||
t.Errorf("token = %q, want empty", tok)
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A gateway-style {code, msg} error (no OAuth error / error_description fields)
|
||||
// must still surface its msg on the typed error, not degrade to a generic
|
||||
// "API error: [code]". Guards the legacy-msg fallback in FetchTAT.
|
||||
func TestFetchTAT_LarkStyleMsg_FallsBackOnTypedError(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 400, respBody: `{"code":99999,"msg":"app ticket invalid"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for {code, msg} response")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "app ticket invalid") {
|
||||
t.Errorf("typed error must carry the Lark msg, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Transient server-side failures (5xx / server_error) are NOT deterministic
|
||||
// credential rejections — they must stay UNTYPED so a probe caller treats them
|
||||
// as upstream noise and stays silent (and retryers can back off).
|
||||
func TestFetchTAT_ServerError_Untyped(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 500, respBody: `{"code":20050,"error":"server_error","error_description":"please retry"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for server_error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("server_error must be UNTYPED (transient), got typed %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rate-limiting is transient, not a deterministic credential rejection — an HTTP
|
||||
// 429 (even with a parseable OAuth body) and the OAuth slow_down error must both
|
||||
// stay UNTYPED so a rate-limited probe stays silent and retryers can back off.
|
||||
func TestFetchTAT_RateLimit_Untyped(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code int
|
||||
body string
|
||||
}{
|
||||
{"http 429", 429, `{"code":99991400,"error":"too_many_requests","error_description":"rate limit exceeded"}`},
|
||||
{"oauth slow_down", 200, `{"error":"slow_down","error_description":"polling too fast"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: tc.code, respBody: tc.body}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for rate-limit")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("rate-limit must be UNTYPED (transient), got typed %T %v", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Non-2xx HTTP with a non-JSON body is ambiguous (not a structured OAuth
|
||||
// rejection) — it must stay UNTYPED so a probe caller treats it as upstream
|
||||
// noise and stays silent.
|
||||
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500, 503} {
|
||||
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
|
||||
@@ -182,12 +254,12 @@ func TestFetchTAT_BrandRouting(t *testing.T) {
|
||||
brand core.LarkBrand
|
||||
wantURL string
|
||||
}{
|
||||
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
{core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"},
|
||||
{core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(string(tc.brand), func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"access_token":"t","token_type":"Bearer"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -65,7 +65,7 @@ var codeMeta = map[int]CodeMeta{
|
||||
|
||||
// CategoryConfig
|
||||
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
|
||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
|
||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
|
||||
|
||||
// CategoryPolicy
|
||||
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},
|
||||
|
||||
@@ -262,19 +262,23 @@ func (b *Bus) handleConn(conn net.Conn) {
|
||||
|
||||
// handleHello registers a consume connection with the hub; reader carries bytes already pulled off conn.
|
||||
func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) {
|
||||
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID)
|
||||
subID := hello.SubscriptionID
|
||||
if subID == "" {
|
||||
subID = hello.EventKey
|
||||
}
|
||||
bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID)
|
||||
bc.SetLogger(b.logger)
|
||||
|
||||
// Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey.
|
||||
firstForKey := b.hub.RegisterAndIsFirst(bc)
|
||||
|
||||
bc.SetCheckLastForKey(func(eventKey string) bool {
|
||||
return b.hub.AcquireCleanupLock(eventKey)
|
||||
bc.SetCheckLastForKey(func(scope string) bool {
|
||||
return b.hub.AcquireCleanupLock(scope)
|
||||
})
|
||||
bc.SetOnClose(func(c *Conn) {
|
||||
b.hub.UnregisterAndIsLast(c)
|
||||
// Release is idempotent and must fire on every disconnect path so waiters don't block forever.
|
||||
b.hub.ReleaseCleanupLock(c.EventKey())
|
||||
b.hub.ReleaseCleanupLock(c.SubscriptionID())
|
||||
b.mu.Lock()
|
||||
delete(b.conns, c)
|
||||
remaining := len(b.conns)
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestRunShutdownWithMultipleConns(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
pipes = append(pipes, server, client)
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i)
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i, "")
|
||||
bc.SetLogger(logger)
|
||||
hub.RegisterAndIsFirst(bc)
|
||||
|
||||
|
||||
@@ -29,9 +29,10 @@ type Conn struct {
|
||||
writeMu sync.Mutex // serialises all net.Conn writes (Encode+SetWriteDeadline is a 2-call sequence)
|
||||
eventKey string
|
||||
eventTypes []string
|
||||
subID string
|
||||
pid int
|
||||
onClose func(*Conn)
|
||||
checkLastForKey func(eventKey string) bool
|
||||
checkLastForKey func(scope string) bool
|
||||
logger *log.Logger
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
@@ -41,7 +42,7 @@ type Conn struct {
|
||||
}
|
||||
|
||||
// NewConn creates a Conn; pass a reader with pre-buffered bytes (handoff from Bus.handleConn) or nil for a fresh one.
|
||||
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int) *Conn {
|
||||
func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int, subID string) *Conn {
|
||||
if reader == nil {
|
||||
reader = bufio.NewReader(conn)
|
||||
}
|
||||
@@ -52,10 +53,20 @@ func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []
|
||||
eventKey: eventKey,
|
||||
eventTypes: eventTypes,
|
||||
pid: pid,
|
||||
subID: subID,
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriptionID returns the subscription identity. Falls back to EventKey
|
||||
// when the stored subID is empty (legacy clients / no-SubscriptionKey EventKeys).
|
||||
func (c *Conn) SubscriptionID() string {
|
||||
if c.subID == "" {
|
||||
return c.eventKey
|
||||
}
|
||||
return c.subID
|
||||
}
|
||||
|
||||
func (c *Conn) SetOnClose(fn func(*Conn)) { c.onClose = fn }
|
||||
|
||||
// SetCheckLastForKey: returning true means "you are the last subscriber, run cleanup".
|
||||
@@ -132,13 +143,19 @@ func (c *Conn) ReaderLoop() {
|
||||
}
|
||||
|
||||
func (c *Conn) handleControlMessage(msg interface{}) {
|
||||
switch m := msg.(type) {
|
||||
switch msg.(type) {
|
||||
case *protocol.Bye:
|
||||
c.shutdown()
|
||||
case *protocol.PreShutdownCheck:
|
||||
// Use the connection's own authoritative subscription identity rather
|
||||
// than recomputing from the incoming message: a stale or mismatched
|
||||
// PreShutdownCheck must not ask about the wrong scope (which would
|
||||
// suppress or mistrigger per-subscription cleanup). Conn.SubscriptionID()
|
||||
// already falls back to EventKey when its stored subID is empty.
|
||||
scope := c.SubscriptionID()
|
||||
lastForKey := true
|
||||
if c.checkLastForKey != nil {
|
||||
lastForKey = c.checkLastForKey(m.EventKey)
|
||||
lastForKey = c.checkLastForKey(scope)
|
||||
}
|
||||
ack := protocol.NewPreShutdownAck(lastForKey)
|
||||
if err := c.writeFrame(ack); err != nil && c.logger != nil {
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestConn_SenderWritesEvents(t *testing.T) {
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345)
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345, "")
|
||||
go bc.SenderLoop()
|
||||
|
||||
bc.SendCh() <- &protocol.Event{
|
||||
@@ -62,7 +62,7 @@ func TestConn_ConcurrentWritesSerialised(t *testing.T) {
|
||||
defer client.Close()
|
||||
|
||||
det := &serializingDetector{Conn: server}
|
||||
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
|
||||
go func() { _, _ = io.Copy(io.Discard, client) }()
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestConn_TrySend_NonEvicting(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
|
||||
for i := 0; i < sendChCap; i++ {
|
||||
if !bc.TrySend(i) {
|
||||
@@ -126,7 +126,7 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345)
|
||||
bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "")
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -142,3 +142,23 @@ func TestConn_ReaderDetectsEOF(t *testing.T) {
|
||||
t.Fatal("ReaderLoop did not exit on EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_SubscriptionID(t *testing.T) {
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "mail.x:abc")
|
||||
if got := conn.SubscriptionID(); got != "mail.x:abc" {
|
||||
t.Errorf("SubscriptionID() = %q, want %q", got, "mail.x:abc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConn_SubscriptionID_EmptyFallsBackToEventKey(t *testing.T) {
|
||||
c1, c2 := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "")
|
||||
if got := conn.SubscriptionID(); got != "mail.x" {
|
||||
t.Errorf("SubscriptionID() with empty input = %q, want fallback %q", got, "mail.x")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,3 +63,134 @@ func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) {
|
||||
t.Errorf("b.conns after failed HelloAck = %d entries, want 0", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_LegacyClient_FallsBackToEventKey: a Hello with empty
|
||||
// subscription_id registers under EventKey (today's behavior preserved).
|
||||
func TestHandleHello_LegacyClient_FallsBackToEventKey(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
// Legacy client: no subscription_id field (empty string).
|
||||
hello := &protocol.Hello{
|
||||
PID: 9999,
|
||||
EventKey: "im.message",
|
||||
EventTypes: []string{"im.message.receive_v1"},
|
||||
SubscriptionID: "", // legacy: empty, should fallback to EventKey
|
||||
}
|
||||
|
||||
br := bufio.NewReader(server)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.handleHello(server, br, hello)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Read the HelloAck from client side to let handleHello complete.
|
||||
clientReader := bufio.NewReader(client)
|
||||
ackLine, err := clientReader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read HelloAck: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handleHello did not return within 3s")
|
||||
}
|
||||
|
||||
// Assertions: registered under EventKey (not a qualified subscription ID).
|
||||
if got := hub.ConnCount(); got != 1 {
|
||||
t.Errorf("hub.ConnCount = %d, want 1", got)
|
||||
}
|
||||
if got := hub.EventKeyCount("im.message"); got != 1 {
|
||||
t.Errorf("hub.EventKeyCount(im.message) = %d, want 1", got)
|
||||
}
|
||||
if got := hub.SubCount("im.message"); got != 1 {
|
||||
t.Errorf("hub.SubCount(im.message) = %d, want 1 (legacy fallback to EventKey)", got)
|
||||
}
|
||||
if got := hub.SubCount("im.message:something"); got != 0 {
|
||||
t.Errorf("hub.SubCount(im.message:something) = %d, want 0 (should not exist)", got)
|
||||
}
|
||||
|
||||
if ackLine == "" {
|
||||
t.Fatal("HelloAck was empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleHello_ModernClient_UsesSubscriptionID: a Hello with
|
||||
// non-empty subscription_id registers under that ID, not EventKey.
|
||||
func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) {
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
hub := NewHub()
|
||||
b := &Bus{
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
conns: make(map[*Conn]struct{}),
|
||||
idleTimer: time.NewTimer(30 * time.Second),
|
||||
shutdownCh: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
server, client := net.Pipe()
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
|
||||
// Modern client: subscription_id explicitly set.
|
||||
subscriptionID := "mail.message:alice@example.com"
|
||||
hello := &protocol.Hello{
|
||||
PID: 8888,
|
||||
EventKey: "mail.message",
|
||||
EventTypes: []string{"mail.message.receive_v1"},
|
||||
SubscriptionID: subscriptionID, // modern: per-resource subscription
|
||||
}
|
||||
|
||||
br := bufio.NewReader(server)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
b.handleHello(server, br, hello)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Read the HelloAck from client side to let handleHello complete.
|
||||
clientReader := bufio.NewReader(client)
|
||||
ackLine, err := clientReader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read HelloAck: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("handleHello did not return within 3s")
|
||||
}
|
||||
|
||||
// Assertions: registered under the subscription_id, not bare EventKey.
|
||||
if got := hub.ConnCount(); got != 1 {
|
||||
t.Errorf("hub.ConnCount = %d, want 1", got)
|
||||
}
|
||||
if got := hub.EventKeyCount("mail.message"); got != 1 {
|
||||
t.Errorf("hub.EventKeyCount(mail.message) = %d, want 1", got)
|
||||
}
|
||||
if got := hub.SubCount(subscriptionID); got != 1 {
|
||||
t.Errorf("hub.SubCount(%q) = %d, want 1 (modern: uses SubscriptionID)", subscriptionID, got)
|
||||
}
|
||||
if got := hub.SubCount("mail.message"); got != 0 {
|
||||
t.Errorf("hub.SubCount(mail.message) = %d, want 0 (modern: NOT registered under bare EventKey)", got)
|
||||
}
|
||||
|
||||
if ackLine == "" {
|
||||
t.Fatal("HelloAck was empty")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
// Subscriber is the interface a connection must satisfy for Hub registration.
|
||||
type Subscriber interface {
|
||||
EventKey() string
|
||||
// SubscriptionID identifies the per-resource subscription for dedup purposes.
|
||||
// When no resource qualifier is needed it equals EventKey.
|
||||
SubscriptionID() string
|
||||
EventTypes() []string
|
||||
SendCh() chan interface{}
|
||||
PID() int
|
||||
@@ -34,8 +37,11 @@ type Subscriber interface {
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
subscribers map[Subscriber]struct{}
|
||||
keyCounts map[string]int
|
||||
// cleanupInProgress[key] holds a channel closed on release; presence means a cleanup lock is held.
|
||||
// subCounts is keyed by SubscriptionID (not EventKey) so that different
|
||||
// per-resource subscriptions sharing the same EventKey are deduped independently.
|
||||
subCounts map[string]int
|
||||
// cleanupInProgress[subscriptionID] holds a channel closed on release;
|
||||
// presence means a cleanup lock is held for that subscription.
|
||||
cleanupInProgress map[string]chan struct{}
|
||||
logger atomic.Pointer[log.Logger]
|
||||
}
|
||||
@@ -43,7 +49,7 @@ type Hub struct {
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
subscribers: make(map[Subscriber]struct{}),
|
||||
keyCounts: make(map[string]int),
|
||||
subCounts: make(map[string]int),
|
||||
cleanupInProgress: make(map[string]chan struct{}),
|
||||
}
|
||||
}
|
||||
@@ -51,7 +57,7 @@ func NewHub() *Hub {
|
||||
// SetLogger attaches a logger (nil tolerated).
|
||||
func (h *Hub) SetLogger(l *log.Logger) { h.logger.Store(l) }
|
||||
|
||||
// UnregisterAndIsLast removes s and reports whether it was last for its EventKey; stale unregisters are no-ops.
|
||||
// UnregisterAndIsLast removes s and reports whether it was last for its SubscriptionID; stale unregisters are no-ops.
|
||||
func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -59,34 +65,35 @@ func (h *Hub) UnregisterAndIsLast(s Subscriber) bool {
|
||||
return false
|
||||
}
|
||||
delete(h.subscribers, s)
|
||||
h.keyCounts[s.EventKey()]--
|
||||
isLast := h.keyCounts[s.EventKey()] == 0
|
||||
sid := s.SubscriptionID()
|
||||
h.subCounts[sid]--
|
||||
isLast := h.subCounts[sid] == 0
|
||||
if isLast {
|
||||
delete(h.keyCounts, s.EventKey())
|
||||
delete(h.subCounts, sid)
|
||||
}
|
||||
return isLast
|
||||
}
|
||||
|
||||
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for eventKey and no lock is held.
|
||||
// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for subscriptionID and no lock is held.
|
||||
// Count==0 is rejected (would block future Register calls). On true return, caller MUST Release.
|
||||
func (h *Hub) AcquireCleanupLock(eventKey string) bool {
|
||||
func (h *Hub) AcquireCleanupLock(subscriptionID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.keyCounts[eventKey] != 1 {
|
||||
if h.subCounts[subscriptionID] != 1 {
|
||||
return false
|
||||
}
|
||||
if _, alreadyLocked := h.cleanupInProgress[eventKey]; alreadyLocked {
|
||||
if _, alreadyLocked := h.cleanupInProgress[subscriptionID]; alreadyLocked {
|
||||
return false
|
||||
}
|
||||
h.cleanupInProgress[eventKey] = make(chan struct{})
|
||||
h.cleanupInProgress[subscriptionID] = make(chan struct{})
|
||||
return true
|
||||
}
|
||||
|
||||
// ReleaseCleanupLock is idempotent; OnClose calls unconditionally.
|
||||
func (h *Hub) ReleaseCleanupLock(eventKey string) {
|
||||
func (h *Hub) ReleaseCleanupLock(subscriptionID string) {
|
||||
h.mu.Lock()
|
||||
ch := h.cleanupInProgress[eventKey]
|
||||
delete(h.cleanupInProgress, eventKey)
|
||||
ch := h.cleanupInProgress[subscriptionID]
|
||||
delete(h.cleanupInProgress, subscriptionID)
|
||||
h.mu.Unlock()
|
||||
if ch != nil {
|
||||
close(ch)
|
||||
@@ -94,23 +101,24 @@ func (h *Hub) ReleaseCleanupLock(eventKey string) {
|
||||
}
|
||||
|
||||
// RegisterAndIsFirst adds s to the hub and reports whether it's the first
|
||||
// subscriber for its EventKey. If a cleanup is in progress for
|
||||
// s.EventKey() (another conn holds the cleanup lock), this waits until
|
||||
// subscriber for its SubscriptionID. If a cleanup is in progress for
|
||||
// s.SubscriptionID() (another conn holds the cleanup lock), this waits until
|
||||
// cleanup releases before registering — closing the PreShutdownCheck ×
|
||||
// Hello TOCTOU race. The wait releases h.mu before blocking on the
|
||||
// channel, so concurrent operations on other keys aren't stalled.
|
||||
// channel, so concurrent operations on other subscriptions aren't stalled.
|
||||
func (h *Hub) RegisterAndIsFirst(s Subscriber) bool {
|
||||
sid := s.SubscriptionID()
|
||||
for {
|
||||
h.mu.Lock()
|
||||
ch, locked := h.cleanupInProgress[s.EventKey()]
|
||||
ch, locked := h.cleanupInProgress[sid]
|
||||
if locked {
|
||||
h.mu.Unlock()
|
||||
<-ch // wait for release, then re-check (defensive against races)
|
||||
continue
|
||||
}
|
||||
isFirst := h.keyCounts[s.EventKey()] == 0
|
||||
isFirst := h.subCounts[sid] == 0
|
||||
h.subscribers[s] = struct{}{}
|
||||
h.keyCounts[s.EventKey()]++
|
||||
h.subCounts[sid]++
|
||||
h.mu.Unlock()
|
||||
return isFirst
|
||||
}
|
||||
@@ -176,11 +184,25 @@ func (h *Hub) ConnCount() int {
|
||||
return len(h.subscribers)
|
||||
}
|
||||
|
||||
// EventKeyCount returns the number of subscribers registered for eventKey.
|
||||
// EventKeyCount returns total subscribers for the given EventKey, aggregating
|
||||
// across all SubscriptionIDs. For per-subscription counts use SubCount.
|
||||
func (h *Hub) EventKeyCount(eventKey string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.keyCounts[eventKey]
|
||||
count := 0
|
||||
for s := range h.subscribers {
|
||||
if s.EventKey() == eventKey {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// SubCount returns the count of subscribers for the given SubscriptionID.
|
||||
func (h *Hub) SubCount(subscriptionID string) int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.subCounts[subscriptionID]
|
||||
}
|
||||
|
||||
// BroadcastSourceStatus fans out a source-level status change to every
|
||||
@@ -205,10 +227,11 @@ func (h *Hub) Consumers() []protocol.ConsumerInfo {
|
||||
result := make([]protocol.ConsumerInfo, 0, len(h.subscribers))
|
||||
for s := range h.subscribers {
|
||||
result = append(result, protocol.ConsumerInfo{
|
||||
PID: s.PID(),
|
||||
EventKey: s.EventKey(),
|
||||
Received: s.Received(),
|
||||
Dropped: s.DroppedCount(),
|
||||
PID: s.PID(),
|
||||
EventKey: s.EventKey(),
|
||||
SubscriptionID: s.SubscriptionID(),
|
||||
Received: s.Received(),
|
||||
Dropped: s.DroppedCount(),
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestHubDroppedCountIncrements(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestPublishAssignsIncrementalSeq(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 10)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestPublishSourceTimeTakesPrecedence(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestPublishSourceTimeFallback(t *testing.T) {
|
||||
server, client := testNetPipe(t)
|
||||
defer server.Close()
|
||||
defer client.Close()
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1)
|
||||
c := NewConn(server, nil, "k", []string{"t"}, 1, "")
|
||||
c.sendCh = make(chan interface{}, 1)
|
||||
h.RegisterAndIsFirst(c)
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ type alwaysFailSubscriber struct {
|
||||
}
|
||||
|
||||
func (s *alwaysFailSubscriber) EventKey() string { return s.eventKey }
|
||||
func (s *alwaysFailSubscriber) SubscriptionID() string { return s.eventKey }
|
||||
func (s *alwaysFailSubscriber) EventTypes() []string { return s.eventTypes }
|
||||
func (s *alwaysFailSubscriber) SendCh() chan interface{} { return s.sendCh }
|
||||
func (s *alwaysFailSubscriber) PID() int { return 0 }
|
||||
@@ -153,6 +154,7 @@ func newRaceSubscriber(key string, types []string, capacity int) *raceSubscriber
|
||||
}
|
||||
|
||||
func (s *raceSubscriber) EventKey() string { return s.eventKey }
|
||||
func (s *raceSubscriber) SubscriptionID() string { return s.eventKey }
|
||||
func (s *raceSubscriber) EventTypes() []string { return s.eventTypes }
|
||||
func (s *raceSubscriber) SendCh() chan interface{} { return s.sendCh }
|
||||
func (s *raceSubscriber) PID() int { return s.pid }
|
||||
|
||||
@@ -5,6 +5,7 @@ package bus
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -235,7 +236,10 @@ func newTestConn(eventKey string, eventTypes []string) *testConn {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testConn) EventKey() string { return c.eventKey }
|
||||
func (c *testConn) EventKey() string { return c.eventKey }
|
||||
|
||||
// SubscriptionID falls back to EventKey for test mocks that don't set a separate subscription ID.
|
||||
func (c *testConn) SubscriptionID() string { return c.eventKey }
|
||||
func (c *testConn) EventTypes() []string { return c.eventTypes }
|
||||
func (c *testConn) SendCh() chan interface{} { return c.sendCh }
|
||||
func (c *testConn) PID() int { return c.pid }
|
||||
@@ -275,3 +279,79 @@ func (c *testConn) TrySend(msg interface{}) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SubscriptionID_Isolation(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
|
||||
|
||||
if !h.RegisterAndIsFirst(s1) {
|
||||
t.Error("s1 should be first for its subscription")
|
||||
}
|
||||
if !h.RegisterAndIsFirst(s2) {
|
||||
t.Error("s2 should ALSO be first (different SubscriptionID)")
|
||||
}
|
||||
if !h.UnregisterAndIsLast(s1) {
|
||||
t.Error("s1 should be last for mail.x:alice")
|
||||
}
|
||||
if !h.UnregisterAndIsLast(s2) {
|
||||
t.Error("s2 should be last for mail.x:bob")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SameSubscriptionID_NotFirst(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:alice")
|
||||
|
||||
if !h.RegisterAndIsFirst(s1) {
|
||||
t.Error("s1 first")
|
||||
}
|
||||
if h.RegisterAndIsFirst(s2) {
|
||||
t.Error("s2 same SubscriptionID should NOT be first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_EventKeyCount_AggregatesAcrossSubscriptions(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
c2, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
defer c2.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob")
|
||||
h.RegisterAndIsFirst(s1)
|
||||
h.RegisterAndIsFirst(s2)
|
||||
if got := h.EventKeyCount("mail.x"); got != 2 {
|
||||
t.Errorf("EventKeyCount(mail.x) = %d, want 2 (aggregated across subscriptions)", got)
|
||||
}
|
||||
if got := h.SubCount("mail.x:alice"); got != 1 {
|
||||
t.Errorf("SubCount(mail.x:alice) = %d, want 1", got)
|
||||
}
|
||||
if got := h.SubCount("mail.x:bob"); got != 1 {
|
||||
t.Errorf("SubCount(mail.x:bob) = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) {
|
||||
h := NewHub()
|
||||
c1, _ := net.Pipe()
|
||||
defer c1.Close()
|
||||
s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice")
|
||||
h.RegisterAndIsFirst(s1)
|
||||
consumers := h.Consumers()
|
||||
if len(consumers) != 1 {
|
||||
t.Fatalf("got %d consumers, want 1", len(consumers))
|
||||
}
|
||||
if consumers[0].SubscriptionID != "mail.x:alice" {
|
||||
t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
@@ -44,7 +45,9 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
|
||||
keyDef, ok := event.Lookup(opts.EventKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown EventKey: %s\nRun 'lark-cli event list' to see available keys", opts.EventKey)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown EventKey: %s", opts.EventKey).
|
||||
WithHint("run `lark-cli event list` to see available keys")
|
||||
}
|
||||
|
||||
if err := validateParams(keyDef, opts.Params); err != nil {
|
||||
@@ -58,6 +61,22 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize params (resolve aliases like "me" -> real email) before fingerprint
|
||||
// compute, PreConsume, Match, Process. Must happen BEFORE doHello so the
|
||||
// SubscriptionID we send to bus reflects canonical values.
|
||||
if keyDef.NormalizeParams != nil {
|
||||
if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"normalize params for %s: %s", opts.EventKey, err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute subscription identity from normalized params + SubscriptionKey flags.
|
||||
subscriptionID := ComputeSubscriptionID(keyDef, opts.Params)
|
||||
|
||||
if opts.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
|
||||
@@ -78,19 +97,24 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType})
|
||||
ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType}, subscriptionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("handshake failed: %w", err)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"event bus handshake failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
var cleanup func() error
|
||||
if ack.FirstForKey && keyDef.PreConsume != nil {
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] running pre-consume setup...\n")
|
||||
}
|
||||
cleanup, err = keyDef.PreConsume(ctx, opts.Runtime, opts.Params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pre-consume failed: %w", err)
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pre-consume failed: %s", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,14 +129,22 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
if cleanup != nil {
|
||||
switch {
|
||||
case r != nil:
|
||||
fmt.Fprintf(errOut, "WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n", opts.EventKey)
|
||||
cleanup()
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n",
|
||||
opts.EventKey)
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: cleanup also failed during panic recovery: %v\n", cleanupErr)
|
||||
}
|
||||
case lastForKey:
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] running cleanup...\n")
|
||||
}
|
||||
cleanup()
|
||||
if !opts.Quiet {
|
||||
if cleanupErr := cleanup(); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut,
|
||||
"WARN: cleanup failed: %v (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)\n",
|
||||
cleanupErr)
|
||||
} else if !opts.Quiet {
|
||||
fmt.Fprintf(errOut, "[event] cleanup done.\n")
|
||||
}
|
||||
}
|
||||
@@ -130,13 +162,13 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
|
||||
if !opts.Quiet {
|
||||
fmt.Fprintln(errOut, listeningText(opts))
|
||||
if !opts.IsTTY {
|
||||
fmt.Fprintln(errOut, stopHintText())
|
||||
fmt.Fprintln(errOut, stopHintText(opts))
|
||||
}
|
||||
}
|
||||
|
||||
writeReadyMarker(errOut, opts)
|
||||
|
||||
return consumeLoop(ctx, conn, br, keyDef, opts, &lastForKey, &emitted)
|
||||
return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted)
|
||||
}
|
||||
|
||||
func truncateDuration(d time.Duration) time.Duration {
|
||||
@@ -152,8 +184,10 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
for _, p := range def.Params {
|
||||
if p.Required {
|
||||
if _, ok := params[p.Name]; !ok {
|
||||
return fmt.Errorf("required param %q missing for EventKey %s. Run 'lark-cli event schema %s' for details",
|
||||
p.Name, def.Key, def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"required param %q missing for EventKey %s", p.Name, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("pass it as --param %s=<value>; run `lark-cli event schema %s` for details", p.Name, def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,11 +203,15 @@ func validateParams(def *event.KeyDefinition, params map[string]string) error {
|
||||
continue
|
||||
}
|
||||
if len(validNames) == 0 {
|
||||
return fmt.Errorf("unknown param %q: EventKey %s accepts no params. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q: EventKey %s accepts no params", k, def.Key).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
}
|
||||
return fmt.Errorf("unknown param %q for EventKey %s. valid params: %s. Run 'lark-cli event schema %s' for details",
|
||||
k, def.Key, strings.Join(validNames, ", "), def.Key)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown param %q for EventKey %s. valid params: %s", k, def.Key, strings.Join(validNames, ", ")).
|
||||
WithParam("--param").
|
||||
WithHint("run `lark-cli event schema %s` for details", def.Key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -213,7 +251,11 @@ func exitReason(ctx context.Context, emitted int64, opts Options) string {
|
||||
return "signal"
|
||||
}
|
||||
|
||||
func stopHintText() string {
|
||||
func stopHintText(opts Options) string {
|
||||
if opts.MaxEvents > 0 || opts.Timeout > 0 {
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>). " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
|
||||
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
|
||||
}
|
||||
|
||||
101
internal/event/consume/consume_test.go
Normal file
101
internal/event/consume/consume_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
"github.com/larksuite/cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// fakeRT is a minimal event.APIClient mock.
|
||||
type fakeRT struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRT) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
return nil, f.err
|
||||
}
|
||||
|
||||
func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) {
|
||||
// Drives the real Run() path: NormalizeParams fails before EnsureBus, so no
|
||||
// bus is contacted, yet the production error-wrapping is exercised — if Run()
|
||||
// ever stops wrapping, this test fails.
|
||||
const key = "test.evt_normalize_fail"
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: key,
|
||||
EventType: key,
|
||||
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}},
|
||||
NormalizeParams: func(_ context.Context, _ event.APIClient, _ map[string]string) error {
|
||||
return errors.New("simulated normalize failure")
|
||||
},
|
||||
})
|
||||
defer event.UnregisterKeyForTest(key)
|
||||
|
||||
err := Run(context.Background(), transport.New(), "app", "", "", Options{
|
||||
EventKey: key,
|
||||
Runtime: &fakeRT{},
|
||||
Quiet: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected Run to fail when NormalizeParams errors")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "normalize params for "+key+":") {
|
||||
t.Errorf("error not wrapped with EventKey prefix: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "simulated normalize failure") {
|
||||
t.Errorf("underlying error not propagated: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoHello_PassesSubscriptionIDToWire(t *testing.T) {
|
||||
a, b := net.Pipe()
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
|
||||
// Server-side: read Hello, decode, assert SubscriptionID, send ack
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
br := bufio.NewReader(b)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
done <- "READ_ERR:" + err.Error()
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
done <- "DECODE_ERR:" + err.Error()
|
||||
return
|
||||
}
|
||||
if hello, ok := msg.(*protocol.Hello); ok {
|
||||
done <- hello.SubscriptionID
|
||||
// send ack so client can return
|
||||
ack := protocol.NewHelloAck("v1", true)
|
||||
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
|
||||
} else {
|
||||
done <- "WRONG_TYPE"
|
||||
}
|
||||
}()
|
||||
|
||||
ack, _, err := doHello(a, "mail.x", []string{"mail.x"}, "mail.x:alice")
|
||||
if err != nil {
|
||||
t.Fatalf("doHello error: %v", err)
|
||||
}
|
||||
if ack == nil {
|
||||
t.Fatal("got nil ack")
|
||||
}
|
||||
got := <-done
|
||||
if got != "mail.x:alice" {
|
||||
t.Errorf("Hello.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
41
internal/event/consume/fingerprint.go
Normal file
41
internal/event/consume/fingerprint.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// ComputeSubscriptionID returns a stable identifier scoped to (EventKey, values
|
||||
// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup
|
||||
// PreConsume/cleanup gates and key Hub counts per-subscription. No SubscriptionKey
|
||||
// params -> returns def.Key verbatim (legacy one-dimensional behavior).
|
||||
//
|
||||
// Stability contract: same EventKey + same normalized param values -> same ID
|
||||
// across CLI versions; changing the encoding requires a wire-format bump.
|
||||
func ComputeSubscriptionID(def *event.KeyDefinition, params map[string]string) string {
|
||||
type kv struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
var subParams []kv
|
||||
for _, p := range def.Params {
|
||||
if !p.SubscriptionKey {
|
||||
continue
|
||||
}
|
||||
subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]})
|
||||
}
|
||||
if len(subParams) == 0 {
|
||||
return def.Key
|
||||
}
|
||||
sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name })
|
||||
raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields
|
||||
sum := sha256.Sum256(raw)
|
||||
return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12])
|
||||
}
|
||||
126
internal/event/consume/fingerprint_test.go
Normal file
126
internal/event/consume/fingerprint_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestComputeSubscriptionID(t *testing.T) {
|
||||
makeDef := func(subKeyNames ...string) *event.KeyDefinition {
|
||||
def := &event.KeyDefinition{Key: "test.evt"}
|
||||
marked := make(map[string]bool, len(subKeyNames))
|
||||
for _, n := range subKeyNames {
|
||||
marked[n] = true
|
||||
}
|
||||
for _, n := range []string{"alpha", "beta", "gamma"} {
|
||||
def.Params = append(def.Params, event.ParamDef{Name: n, SubscriptionKey: marked[n]})
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
t.Run("no SubscriptionKey params returns EventKey verbatim", func(t *testing.T) {
|
||||
def := makeDef()
|
||||
got := ComputeSubscriptionID(def, map[string]string{"alpha": "x", "beta": "y"})
|
||||
if got != "test.evt" {
|
||||
t.Errorf("got %q, want %q", got, "test.evt")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single SubscriptionKey param: non-sub params do not leak into ID", func(t *testing.T) {
|
||||
def := makeDef("alpha")
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "ignored"})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "different"})
|
||||
if id1 != id2 {
|
||||
t.Errorf("non-SubscriptionKey param change leaked into ID: %q vs %q", id1, id2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different SubscriptionKey value produces different ID", func(t *testing.T) {
|
||||
def := makeDef("alpha")
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "v1"})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "v2"})
|
||||
if id1 == id2 {
|
||||
t.Errorf("different values produced same ID: %q", id1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_Stability(t *testing.T) {
|
||||
// Param order in the ParamDef list must not affect the result (sorted by name internally).
|
||||
def1 := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{
|
||||
{Name: "b", SubscriptionKey: true},
|
||||
{Name: "a", SubscriptionKey: true},
|
||||
},
|
||||
}
|
||||
def2 := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{
|
||||
{Name: "a", SubscriptionKey: true},
|
||||
{Name: "b", SubscriptionKey: true},
|
||||
},
|
||||
}
|
||||
id1 := ComputeSubscriptionID(def1, map[string]string{"a": "1", "b": "2"})
|
||||
id2 := ComputeSubscriptionID(def2, map[string]string{"a": "1", "b": "2"})
|
||||
if id1 != id2 {
|
||||
t.Errorf("order-sensitive: id1=%q id2=%q", id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_Format(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "mail.user_mailbox.event.message_received_v1",
|
||||
Params: []event.ParamDef{{Name: "mailbox", SubscriptionKey: true}},
|
||||
}
|
||||
id := ComputeSubscriptionID(def, map[string]string{"mailbox": "liuxinyang@example.com"})
|
||||
prefix := "mail.user_mailbox.event.message_received_v1:"
|
||||
if !strings.HasPrefix(id, prefix) {
|
||||
t.Fatalf("missing prefix: %q", id)
|
||||
}
|
||||
suffix := strings.TrimPrefix(id, prefix)
|
||||
if len(suffix) != 16 {
|
||||
t.Errorf("fingerprint length = %d, want 16", len(suffix))
|
||||
}
|
||||
for _, c := range suffix {
|
||||
isValid := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_'
|
||||
if !isValid {
|
||||
t.Errorf("non-base64URL char in fingerprint: %q", suffix)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_UnicodeAndSpecialChars(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{{Name: "value", SubscriptionKey: true}},
|
||||
}
|
||||
for _, val := range []string{"中文", "emoji🚀", "with spaces", "with:colons", "with\"quotes"} {
|
||||
id := ComputeSubscriptionID(def, map[string]string{"value": val})
|
||||
if !strings.HasPrefix(id, "test.evt:") || len(id) != len("test.evt:")+16 {
|
||||
t.Errorf("ID malformed for value=%q: %q (len=%d)", val, id, len(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeSubscriptionID_EmptyValue(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Params: []event.ParamDef{{Name: "x", SubscriptionKey: true}},
|
||||
}
|
||||
id1 := ComputeSubscriptionID(def, map[string]string{"x": ""})
|
||||
id2 := ComputeSubscriptionID(def, map[string]string{}) // missing entirely
|
||||
if id1 != id2 {
|
||||
t.Errorf("empty value should be indistinguishable from missing: %q vs %q", id1, id2)
|
||||
}
|
||||
id3 := ComputeSubscriptionID(def, map[string]string{"x": "nonempty"})
|
||||
if id1 == id3 {
|
||||
t.Errorf("empty and nonempty produced same ID: %q", id1)
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read de
|
||||
|
||||
// doHello returns a bufio.Reader holding any bytes already pulled off conn so events
|
||||
// buffered with the ack in one TCP segment aren't dropped.
|
||||
func doHello(conn net.Conn, eventKey string, eventTypes []string) (*protocol.HelloAck, *bufio.Reader, error) {
|
||||
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1")
|
||||
func doHello(conn net.Conn, eventKey string, eventTypes []string, subscriptionID string) (*protocol.HelloAck, *bufio.Reader, error) {
|
||||
hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1", subscriptionID)
|
||||
if err := protocol.EncodeWithDeadline(conn, hello, protocol.WriteTimeout); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestDoHello_ReadDeadline(t *testing.T) {
|
||||
start := time.Now()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := doHello(client, "im.msg", []string{"im.msg"})
|
||||
_, _, err := doHello(client, "im.msg", []string{"im.msg"}, "")
|
||||
done <- err
|
||||
}()
|
||||
|
||||
|
||||
@@ -8,17 +8,21 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// CompileJQ compiles once for hot-path reuse; exported so callers can preflight before side effects.
|
||||
func CompileJQ(expr string) (*gojq.Code, error) {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid jq expression: %w", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid jq expression: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jq compile error: %w", err)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"jq compile error: %s", err).WithParam("--jq").WithCause(err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
@@ -50,12 +50,32 @@ func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Content(t *testing.T) {
|
||||
got := stopHintText()
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
func TestStopHintText_Unbounded(t *testing.T) {
|
||||
got := stopHintText(Options{})
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup", "close stdin"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText missing %q; got %q", s, got)
|
||||
t.Errorf("stopHintText(unbounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
|
||||
func TestStopHintText_Bounded(t *testing.T) {
|
||||
cases := []Options{
|
||||
{MaxEvents: 1},
|
||||
{Timeout: 30 * time.Second},
|
||||
}
|
||||
for _, opts := range cases {
|
||||
got := stopHintText(opts)
|
||||
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
|
||||
for _, s := range mustContain {
|
||||
if !bytes.Contains([]byte(got), []byte(s)) {
|
||||
t.Errorf("stopHintText(bounded) missing %q; got %q", s, got)
|
||||
}
|
||||
}
|
||||
if bytes.Contains([]byte(got), []byte("close stdin")) {
|
||||
t.Errorf("stopHintText(bounded) must not contain \"close stdin\"; got %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
// consumeLoop reads events and dispatches to workers; cancels on terminal sink errors.
|
||||
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, lastForKey *bool, emitted *atomic.Int64) error {
|
||||
func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, subscriptionID string, lastForKey *bool, emitted *atomic.Int64) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
@@ -185,7 +185,7 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
|
||||
close(stopReader)
|
||||
<-readerDone
|
||||
conn.SetReadDeadline(time.Time{})
|
||||
*lastForKey = checkLastForKey(conn, opts.EventKey)
|
||||
*lastForKey = checkLastForKey(conn, opts.EventKey, subscriptionID)
|
||||
conn.Close()
|
||||
case <-allDone:
|
||||
// bus-side close; can't query, assume last
|
||||
@@ -199,13 +199,19 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e
|
||||
|
||||
// processAndOutput returns (wrote, err); err non-nil only for sink.Write failures.
|
||||
func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: evt.EventType,
|
||||
Payload: evt.Payload,
|
||||
}
|
||||
|
||||
// Synchronous Match filter runs before any work (Process / sink write).
|
||||
if keyDef.Match != nil && !keyDef.Match(raw, opts.Params) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var result json.RawMessage
|
||||
|
||||
if keyDef.Process != nil {
|
||||
raw := &event.RawEvent{
|
||||
EventType: evt.EventType,
|
||||
Payload: evt.Payload,
|
||||
}
|
||||
var err error
|
||||
result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,10 +5,13 @@ package consume
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
@@ -20,6 +23,16 @@ func TestCompileJQReportsErrorEarly(t *testing.T) {
|
||||
if !strings.Contains(msg, "compile") && !strings.Contains(msg, "parse") && !strings.Contains(msg, "invalid") {
|
||||
t.Errorf("error should mention compile/parse/invalid, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--jq" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--jq")
|
||||
}
|
||||
if errors.Unwrap(err) == nil {
|
||||
t.Error("compile error should preserve its cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileJQReturnsUsableCode(t *testing.T) {
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
|
||||
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
|
||||
if err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 2 {
|
||||
@@ -169,7 +169,7 @@ func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil {
|
||||
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
|
||||
t.Fatalf("consumeLoop: %v", err)
|
||||
}
|
||||
if got := emitted.Load(); got != 1 {
|
||||
@@ -196,12 +196,96 @@ func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) {
|
||||
|
||||
var lastForKey bool
|
||||
var emitted atomic.Int64
|
||||
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted)
|
||||
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
|
||||
if err == nil {
|
||||
t.Fatal("consumeLoop should fail immediately on bad jq expression")
|
||||
}
|
||||
}
|
||||
|
||||
// captureSink is a minimal Sink for unit-testing processAndOutput directly.
|
||||
type captureSink struct {
|
||||
written []json.RawMessage
|
||||
}
|
||||
|
||||
func (s *captureSink) Write(data json.RawMessage) error {
|
||||
s.written = append(s.written, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_DropsEvent(t *testing.T) {
|
||||
calledProcess := false
|
||||
keyDef := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Match: func(raw *event.RawEvent, params map[string]string) bool {
|
||||
return false
|
||||
},
|
||||
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
|
||||
calledProcess = true
|
||||
return json.RawMessage(`{}`), nil
|
||||
},
|
||||
}
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wrote {
|
||||
t.Error("Match returned false but event was written")
|
||||
}
|
||||
if calledProcess {
|
||||
t.Error("Process was called even though Match returned false")
|
||||
}
|
||||
if len(sink.written) != 0 {
|
||||
t.Errorf("sink received %d events, want 0", len(sink.written))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_NilAcceptsAll(t *testing.T) {
|
||||
keyDef := &event.KeyDefinition{Key: "test.evt"} // no Match, no Process
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil || !wrote {
|
||||
t.Errorf("expected wrote=true err=nil; got wrote=%v err=%v", wrote, err)
|
||||
}
|
||||
if len(sink.written) != 1 {
|
||||
t.Errorf("sink received %d events, want 1", len(sink.written))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAndOutput_Match_RunsBeforeProcess(t *testing.T) {
|
||||
// Record the actual call sequence — a bare call-count check would still
|
||||
// pass if Process ran before Match.
|
||||
var order []string
|
||||
keyDef := &event.KeyDefinition{
|
||||
Key: "test.evt",
|
||||
Match: func(raw *event.RawEvent, params map[string]string) bool {
|
||||
order = append(order, "match")
|
||||
return true
|
||||
},
|
||||
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
|
||||
order = append(order, "process")
|
||||
return raw.Payload, nil
|
||||
},
|
||||
}
|
||||
sink := &captureSink{}
|
||||
wrote, err := processAndOutput(context.Background(), keyDef,
|
||||
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{}`)},
|
||||
Options{}, sink, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !wrote {
|
||||
t.Error("expected wrote=true")
|
||||
}
|
||||
if len(order) != 2 || order[0] != "match" || order[1] != "process" {
|
||||
t.Errorf("call order = %v, want [match process]", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTerminalSinkError(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
|
||||
@@ -16,8 +16,8 @@ const preShutdownAckTimeout = 2 * time.Second
|
||||
|
||||
// checkLastForKey atomically reserves a cleanup lock; on any error defaults to true
|
||||
// (cleanup-on-error is safer than leaking server state). Discards non-ack frames in flight.
|
||||
func checkLastForKey(conn net.Conn, eventKey string) bool {
|
||||
msg := protocol.NewPreShutdownCheck(eventKey)
|
||||
func checkLastForKey(conn net.Conn, eventKey string, subscriptionID string) bool {
|
||||
msg := protocol.NewPreShutdownCheck(eventKey, subscriptionID)
|
||||
if err := protocol.EncodeWithDeadline(conn, msg, protocol.WriteTimeout); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package consume
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
@@ -38,7 +40,7 @@ func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
if got != false {
|
||||
t.Errorf("checkLastForKey = %v, want false", got)
|
||||
}
|
||||
@@ -62,7 +64,7 @@ func TestCheckLastForKey_ReturnsAckValue(t *testing.T) {
|
||||
_ = protocol.Encode(server, ack)
|
||||
}()
|
||||
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
if got != true {
|
||||
t.Errorf("checkLastForKey = %v, want true", got)
|
||||
}
|
||||
@@ -83,7 +85,7 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
got := checkLastForKey(client, "im.msg")
|
||||
got := checkLastForKey(client, "im.msg", "")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if got != true {
|
||||
@@ -93,3 +95,39 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) {
|
||||
t.Errorf("elapsed = %v, expected ~%v (timeout-bounded)", elapsed, preShutdownAckTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLastForKey_SendsSubscriptionID(t *testing.T) {
|
||||
a, b := net.Pipe()
|
||||
defer a.Close()
|
||||
defer b.Close()
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
br := bufio.NewReader(b)
|
||||
line, err := protocol.ReadFrame(br)
|
||||
if err != nil {
|
||||
done <- "READ_ERR"
|
||||
return
|
||||
}
|
||||
msg, err := protocol.Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
done <- "DECODE_ERR"
|
||||
return
|
||||
}
|
||||
check, ok := msg.(*protocol.PreShutdownCheck)
|
||||
if !ok {
|
||||
done <- "WRONG_TYPE"
|
||||
return
|
||||
}
|
||||
done <- check.SubscriptionID
|
||||
// Reply with ack so client returns
|
||||
ack := protocol.NewPreShutdownAck(true)
|
||||
_ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout)
|
||||
}()
|
||||
|
||||
_ = checkLastForKey(a, "mail.x", "mail.x:alice")
|
||||
got := <-done
|
||||
if got != "mail.x:alice" {
|
||||
t.Errorf("PreShutdownCheck.SubscriptionID on wire = %q, want %q", got, "mail.x:alice")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -23,7 +24,8 @@ type Sink interface {
|
||||
func newSink(opts Options) (Sink, error) {
|
||||
if opts.OutputDir != "" {
|
||||
if err := vfs.MkdirAll(opts.OutputDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create output dir: %w", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"create output dir: %s", err).WithCause(err)
|
||||
}
|
||||
// PID disambiguates filenames across processes sharing a Dir.
|
||||
return &DirSink{Dir: opts.OutputDir, pid: os.Getpid()}, nil
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/protocol"
|
||||
@@ -51,10 +52,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
} else {
|
||||
fmt.Fprintf(errOut, "[event] remote connection check: online_instance_cnt=%d\n", count)
|
||||
if count > 0 {
|
||||
return nil, fmt.Errorf("another event bus is already connected to this app "+
|
||||
"(%d active connection(s) detected via API).\n"+
|
||||
"Only one bus should run globally to avoid duplicate event delivery.\n"+
|
||||
"Use 'lark-cli event status' to check, or 'lark-cli event stop' on the other machine first", count)
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"another event bus is already connected to this app (%d active connection(s) detected via API); only one bus should run globally to avoid duplicate event delivery", count).
|
||||
WithHint("use `lark-cli event status` to check, or `lark-cli event stop` on the other machine first")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -65,8 +65,10 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
pid, forkErr := forkBus(tr, appID, profileName, domain)
|
||||
if forkErr != nil && !errors.Is(forkErr, lockfile.ErrHeld) {
|
||||
eventsRoot := filepath.Join(core.GetConfigDir(), "events")
|
||||
return nil, fmt.Errorf("failed to start event bus daemon: %w\n"+
|
||||
"Check: disk space, permissions on %s, and 'lark-cli doctor'", forkErr, eventsRoot)
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to start event bus daemon: %s", forkErr).
|
||||
WithCause(forkErr).
|
||||
WithHint("check disk space, permissions on %s, and `lark-cli doctor`", eventsRoot)
|
||||
}
|
||||
if pid > 0 {
|
||||
announceForkedBus(errOut, pid)
|
||||
@@ -88,7 +90,9 @@ func EnsureBus(ctx context.Context, tr transport.IPC, appID, profileName, domain
|
||||
fmt.Fprintln(errOut, "[event] event bus exited unexpectedly.")
|
||||
fmt.Fprintln(errOut, "[event] please check app credentials (lark-cli config show) and retry.")
|
||||
fmt.Fprintf(errOut, "[event] logs: %s\n", logPath)
|
||||
return nil, fmt.Errorf("failed to connect to event bus within %v (app=%s)", dialTimeout, appID)
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to connect to event bus within %v (app=%s)", dialTimeout, appID).
|
||||
WithHint("check app credentials (`lark-cli config show`) and retry; bus logs: %s", logPath)
|
||||
}
|
||||
|
||||
// probeAndDialBus distinguishes a healthy bus from a mid-shutdown listener via StatusQuery first.
|
||||
|
||||
99
internal/event/consume/startup_guard_test.go
Normal file
99
internal/event/consume/startup_guard_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// failDialTransport refuses every dial so EnsureBus falls through to the
|
||||
// remote-connection check without a local bus.
|
||||
type failDialTransport struct{}
|
||||
|
||||
func (failDialTransport) Listen(string) (net.Listener, error) { return nil, errors.New("no listen") }
|
||||
func (failDialTransport) Dial(string) (net.Conn, error) { return nil, errors.New("refused") }
|
||||
func (failDialTransport) Address(string) string { return "guard-test-addr" }
|
||||
func (failDialTransport) Cleanup(string) {}
|
||||
|
||||
// remoteBusyAPIClient reports active remote WebSocket connections.
|
||||
type remoteBusyAPIClient struct{ count int }
|
||||
|
||||
func (c remoteBusyAPIClient) CallAPI(context.Context, string, string, interface{}) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"code":0,"msg":"ok","data":{"online_instance_cnt":` +
|
||||
strconv.Itoa(c.count) + `}}`), nil
|
||||
}
|
||||
|
||||
func TestEnsureBus_RemoteBusAlreadyConnectedIsFailedPrecondition(t *testing.T) {
|
||||
conn, err := EnsureBus(context.Background(), failDialTransport{},
|
||||
"cli_guard_test", "", "", remoteBusyAPIClient{count: 2}, io.Discard)
|
||||
if conn != nil {
|
||||
t.Fatal("expected nil conn when a remote bus is already connected")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected single-bus guard error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event stop") {
|
||||
t.Errorf("hint should point at `event stop`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_UnknownEventKeyIsTypedValidation(t *testing.T) {
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "bogus.run.key",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown EventKey error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "event list") {
|
||||
t.Errorf("hint should point at `event list`, got: %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_InvalidJQFailsBeforeAnySideEffect(t *testing.T) {
|
||||
event.RegisterKey(event.KeyDefinition{
|
||||
Key: "consume.runtest.jq",
|
||||
EventType: "consume.runtest.jq_v1",
|
||||
Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{}`)}},
|
||||
})
|
||||
err := Run(context.Background(), failDialTransport{}, "cli_x", "", "", Options{
|
||||
EventKey: "consume.runtest.jq",
|
||||
JQExpr: "[invalid{{{",
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected jq validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Param != "--jq" {
|
||||
t.Errorf("param = %q, want %q", ve.Param, "--jq")
|
||||
}
|
||||
}
|
||||
64
internal/event/consume/validate_params_test.go
Normal file
64
internal/event/consume/validate_params_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package consume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func requireParamValidationError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want %s/%q", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument, "--param")
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("param validation error should hint at `lark-cli event schema`")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateParams_RequiredMissing(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id", Required: true}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParam(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "chat_id"}},
|
||||
}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_UnknownParamNoParamsAccepted(t *testing.T) {
|
||||
def := &event.KeyDefinition{Key: "x.test"}
|
||||
requireParamValidationError(t, validateParams(def, map[string]string{"nope": "1"}))
|
||||
}
|
||||
|
||||
func TestValidateParams_DefaultAppliedAndValidPasses(t *testing.T) {
|
||||
def := &event.KeyDefinition{
|
||||
Key: "x.test",
|
||||
Params: []event.ParamDef{{Name: "mode", Required: true, Default: "all"}},
|
||||
}
|
||||
params := map[string]string{}
|
||||
if err := validateParams(def, params); err != nil {
|
||||
t.Fatalf("default should satisfy required param, got: %v", err)
|
||||
}
|
||||
if params["mode"] != "all" {
|
||||
t.Errorf("default not applied, params=%v", params)
|
||||
}
|
||||
}
|
||||
@@ -77,3 +77,88 @@ func TestDecodeUnknownType(t *testing.T) {
|
||||
t.Error("expected error for unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeHello_WithSubscriptionID(t *testing.T) {
|
||||
msg := &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: 12345,
|
||||
EventKey: "mail.user_mailbox.event.message_received_v1",
|
||||
EventTypes: []string{"mail.user_mailbox.event.message_received_v1"},
|
||||
Version: "v1",
|
||||
SubscriptionID: "mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs",
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := buf.Bytes()
|
||||
if !bytes.Contains(line, []byte(`"subscription_id":"mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs"`)) {
|
||||
t.Errorf("subscription_id not serialized: %s", string(line))
|
||||
}
|
||||
decoded, err := Decode(bytes.TrimRight(line, "\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hello, ok := decoded.(*Hello)
|
||||
if !ok {
|
||||
t.Fatalf("expected *Hello, got %T", decoded)
|
||||
}
|
||||
if hello.SubscriptionID != msg.SubscriptionID {
|
||||
t.Errorf("roundtrip subscription_id: got %q want %q", hello.SubscriptionID, msg.SubscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeHello_EmptySubscriptionIDOmitted(t *testing.T) {
|
||||
msg := &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: 1,
|
||||
EventKey: "k",
|
||||
EventTypes: []string{"k"},
|
||||
Version: "v1",
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(buf.Bytes(), []byte("subscription_id")) {
|
||||
t.Errorf("empty subscription_id should be omitted: %s", buf.String())
|
||||
}
|
||||
decoded, _ := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
hello := decoded.(*Hello)
|
||||
if hello.SubscriptionID != "" {
|
||||
t.Errorf("got %q, want empty", hello.SubscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodePreShutdownCheck_WithSubscriptionID(t *testing.T) {
|
||||
msg := &PreShutdownCheck{
|
||||
Type: MsgTypePreShutdownCheck,
|
||||
EventKey: "mail.x",
|
||||
SubscriptionID: "mail.x:abc",
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := Decode(bytes.TrimRight(buf.Bytes(), "\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := decoded.(*PreShutdownCheck)
|
||||
if got.SubscriptionID != msg.SubscriptionID {
|
||||
t.Errorf("roundtrip: got %q want %q", got.SubscriptionID, msg.SubscriptionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusResponse_ConsumerInfo_SubscriptionID(t *testing.T) {
|
||||
msg := NewStatusResponse(7, 120, 1, []ConsumerInfo{
|
||||
{PID: 99, EventKey: "mail.x", SubscriptionID: "mail.x:abc", Received: 5, Dropped: 0},
|
||||
})
|
||||
buf := &bytes.Buffer{}
|
||||
if err := Encode(buf, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte(`"subscription_id":"mail.x:abc"`)) {
|
||||
t.Errorf("ConsumerInfo.SubscriptionID missing from JSON: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,12 @@ type SourceStatus struct {
|
||||
}
|
||||
|
||||
type Hello struct {
|
||||
Type string `json:"type"`
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
EventTypes []string `json:"event_types"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
EventTypes []string `json:"event_types"`
|
||||
Version string `json:"version"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey on bus side
|
||||
}
|
||||
|
||||
type HelloAck struct {
|
||||
@@ -61,10 +62,11 @@ type Bye struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// PreShutdownCheck atomically reserves the cleanup lock for EventKey.
|
||||
// PreShutdownCheck atomically reserves the cleanup lock for (EventKey, SubscriptionID).
|
||||
type PreShutdownCheck struct {
|
||||
Type string `json:"type"`
|
||||
EventKey string `json:"event_key"`
|
||||
Type string `json:"type"`
|
||||
EventKey string `json:"event_key"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey
|
||||
}
|
||||
|
||||
type PreShutdownAck struct {
|
||||
@@ -77,10 +79,11 @@ type StatusQuery struct {
|
||||
}
|
||||
|
||||
type ConsumerInfo struct {
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
Received int64 `json:"received"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
PID int `json:"pid"`
|
||||
EventKey string `json:"event_key"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty"`
|
||||
Received int64 `json:"received"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
@@ -95,13 +98,14 @@ type Shutdown struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func NewHello(pid int, eventKey string, eventTypes []string, version string) *Hello {
|
||||
func NewHello(pid int, eventKey string, eventTypes []string, version string, subscriptionID string) *Hello {
|
||||
return &Hello{
|
||||
Type: MsgTypeHello,
|
||||
PID: pid,
|
||||
EventKey: eventKey,
|
||||
EventTypes: eventTypes,
|
||||
Version: version,
|
||||
Type: MsgTypeHello,
|
||||
PID: pid,
|
||||
EventKey: eventKey,
|
||||
EventTypes: eventTypes,
|
||||
Version: version,
|
||||
SubscriptionID: subscriptionID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +128,8 @@ func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.Ra
|
||||
}
|
||||
}
|
||||
|
||||
func NewPreShutdownCheck(eventKey string) *PreShutdownCheck {
|
||||
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey}
|
||||
func NewPreShutdownCheck(eventKey, subscriptionID string) *PreShutdownCheck {
|
||||
return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey, SubscriptionID: subscriptionID}
|
||||
}
|
||||
|
||||
func NewPreShutdownAck(lastForKey bool) *PreShutdownAck {
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
// Every NewXxx helper must set the Type discriminator (Decode rejects messages without it).
|
||||
func TestConstructors_PinTypeField(t *testing.T) {
|
||||
if got := NewHello(1, "k", []string{"t"}, "v1"); got.Type != MsgTypeHello {
|
||||
if got := NewHello(1, "k", []string{"t"}, "v1", ""); got.Type != MsgTypeHello {
|
||||
t.Errorf("NewHello.Type = %q, want %q", got.Type, MsgTypeHello)
|
||||
}
|
||||
if got := NewHelloAck("v1", true); got.Type != MsgTypeHelloAck || !got.FirstForKey {
|
||||
@@ -26,7 +26,7 @@ func TestConstructors_PinTypeField(t *testing.T) {
|
||||
if got := NewEvent("im.msg", "e1", "", 7, json.RawMessage(`{}`)); got.Type != MsgTypeEvent || got.Seq != 7 {
|
||||
t.Errorf("NewEvent mismatch: %+v", got)
|
||||
}
|
||||
if got := NewPreShutdownCheck("k"); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
|
||||
if got := NewPreShutdownCheck("k", ""); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" {
|
||||
t.Errorf("NewPreShutdownCheck mismatch: %+v", got)
|
||||
}
|
||||
if got := NewPreShutdownAck(true); got.Type != MsgTypePreShutdownAck || !got.LastForKey {
|
||||
@@ -63,7 +63,7 @@ func TestEncode_DecodeRoundtripAllTypes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
roundtrip(t, NewHelloAck("v1", true), &HelloAck{})
|
||||
roundtrip(t, NewPreShutdownCheck("im.msg"), &PreShutdownCheck{})
|
||||
roundtrip(t, NewPreShutdownCheck("im.msg", ""), &PreShutdownCheck{})
|
||||
roundtrip(t, NewPreShutdownAck(false), &PreShutdownAck{})
|
||||
roundtrip(t, NewStatusQuery(), &StatusQuery{})
|
||||
roundtrip(t, NewStatusResponse(7, 120, 1, []ConsumerInfo{{PID: 99, EventKey: "k"}}), &StatusResponse{})
|
||||
|
||||
@@ -55,6 +55,23 @@ type ParamDef struct {
|
||||
Default string `json:"default,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Values []ParamValue `json:"values,omitempty"`
|
||||
|
||||
// SubscriptionKey marks this param as part of the subscription identity.
|
||||
// Two consumers of the same EventKey but different values for any
|
||||
// SubscriptionKey-marked param are treated as DISTINCT subscriptions:
|
||||
// PreConsume runs once per (EventKey, SubscriptionID), cleanup runs once per
|
||||
// (EventKey, SubscriptionID).
|
||||
//
|
||||
// CONTRACT: only mark a param SubscriptionKey if the EventKey's server-side
|
||||
// subscribe/unsubscribe API is itself scoped to that resource. Lark keys the
|
||||
// subscription record by (app, user, event_type) and overwrites it rather
|
||||
// than reference-counting, so for a non-per-resource API the cleanup of one
|
||||
// resource's last consumer unsubscribes the shared record and silently cuts
|
||||
// off every other resource sharing that event_type.
|
||||
//
|
||||
// Default false = the param is a filter / formatting / metadata param
|
||||
// and does not affect subscription identity.
|
||||
SubscriptionKey bool `json:"subscription_key,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessFunc = func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error)
|
||||
@@ -83,10 +100,44 @@ type KeyDefinition struct {
|
||||
|
||||
Schema SchemaDef `json:"schema"`
|
||||
|
||||
// NormalizeParams canonicalizes param values BEFORE fingerprint compute,
|
||||
// PreConsume, Match, and Process. Mutates the params map in place.
|
||||
// May call OAPI; runs once per consumer at startup.
|
||||
//
|
||||
// Use cases: resolve aliases ("me" -> real email, a name -> an ID),
|
||||
// trim whitespace. On error, consume fails (no retry); caller gets the
|
||||
// wrapped error.
|
||||
//
|
||||
// Default nil = no normalization, params pass through unchanged.
|
||||
NormalizeParams func(ctx context.Context, rt APIClient, params map[string]string) error `json:"-"`
|
||||
|
||||
// Process required when Schema.Custom is Processed output; must be nil when Native is used.
|
||||
//
|
||||
// Convention: returning (nil, nil) signals "drop this event" — the
|
||||
// consumer loop will skip writing it to sink and not advance the
|
||||
// emitted counter. Useful for async filtering (e.g. fetch metadata,
|
||||
// drop if folder doesn't match). For sync filters that don't need
|
||||
// OAPI, use Match instead.
|
||||
Process func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) `json:"-"`
|
||||
|
||||
PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func(), err error) `json:"-"`
|
||||
// Match is a synchronous payload filter run on every received event
|
||||
// BEFORE Process. Return false to drop the event without further work.
|
||||
//
|
||||
// Signature deliberately omits ctx/rt to physically enforce "no OAPI
|
||||
// calls in Match". For filters that need a metadata fetch first, use
|
||||
// Process and return nil to drop.
|
||||
//
|
||||
// Default nil = accept all events.
|
||||
Match func(raw *RawEvent, params map[string]string) bool `json:"-"`
|
||||
|
||||
// PreConsume runs once per (EventKey, SubscriptionID) when this consumer
|
||||
// is first for that scope. Returns a cleanup function that the framework
|
||||
// invokes when this consumer is the last for its scope.
|
||||
//
|
||||
// The cleanup's error return is honored: on nil the framework prints
|
||||
// "[event] cleanup done."; on non-nil it prints a WARN with an
|
||||
// idempotency note.
|
||||
PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func() error, err error) `json:"-"`
|
||||
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
|
||||
|
||||
@@ -35,9 +35,12 @@ const (
|
||||
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
|
||||
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
|
||||
|
||||
// TAT-endpoint variant of the "wrong app credentials" condition.
|
||||
// /open-apis/auth/v3/tenant_access_token/internal returns code 10014
|
||||
// ("app secret invalid") instead of 99991543 when the secret is wrong.
|
||||
// "Wrong app credentials" code from the LEGACY TAT endpoint
|
||||
// (/open-apis/auth/v3/tenant_access_token/internal returns 10014, "app secret
|
||||
// invalid", instead of 99991543). Since the OAuth v3 migration the CLI mints
|
||||
// TAT via accounts/oauth/v3/token and reports this as the OAuth invalid_client
|
||||
// error, so it no longer emits 10014 itself; the constant + codemeta mapping
|
||||
// are retained as a defensive fallback should 10014 still arrive.
|
||||
LarkErrTATInvalidSecret = 10014
|
||||
|
||||
// Rate limit.
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
"en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" },
|
||||
"zh": { "title": "妙记", "description": "妙记信息获取、内容查询" }
|
||||
},
|
||||
"note": {
|
||||
"en": { "title": "Note", "description": "Meeting note detail and unified transcript retrieval" },
|
||||
"zh": { "title": "会议纪要", "description": "会议纪要详情与 unified 逐字稿查询" }
|
||||
},
|
||||
"sheets": {
|
||||
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
|
||||
"zh": { "title": "电子表格", "description": "电子表格操作" }
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
// carrying their own copy.
|
||||
package suggest
|
||||
|
||||
import "sort"
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Levenshtein computes the classic edit distance between two strings. It is
|
||||
// rune-aware, so it is correct for multi-byte input.
|
||||
@@ -51,22 +54,29 @@ func Levenshtein(a, b string) int {
|
||||
// signal of intent that raw edit distance misses.
|
||||
func Closest(typed string, candidates []string, maxN int) []string {
|
||||
type scored struct {
|
||||
name string
|
||||
prefix int
|
||||
dist int
|
||||
name string
|
||||
contain bool
|
||||
prefix int
|
||||
dist int
|
||||
}
|
||||
limit := editLimit(typed)
|
||||
ranked := make([]scored, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
p := sharedPrefixLen(typed, c)
|
||||
d := Levenshtein(typed, c)
|
||||
// Keep only plausible matches: a meaningful shared prefix, or an edit
|
||||
// distance within budget. Drop everything else so the hint stays short.
|
||||
if p >= 3 || d <= limit {
|
||||
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
|
||||
ct := containsSegment(typed, c)
|
||||
// Keep only plausible matches: a meaningful shared prefix, an edit
|
||||
// distance within budget, or one name containing the other (a missing
|
||||
// namespace prefix like "+block-list" vs "+base-block-list"). Drop
|
||||
// everything else so the hint stays short.
|
||||
if p >= 3 || d <= limit || ct {
|
||||
ranked = append(ranked, scored{name: c, contain: ct, prefix: p, dist: d})
|
||||
}
|
||||
}
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
if ranked[i].contain != ranked[j].contain {
|
||||
return ranked[i].contain
|
||||
}
|
||||
if ranked[i].prefix != ranked[j].prefix {
|
||||
return ranked[i].prefix > ranked[j].prefix
|
||||
}
|
||||
@@ -94,6 +104,21 @@ func editLimit(s string) int {
|
||||
return 2
|
||||
}
|
||||
|
||||
// containsSegment reports whether one name contains the other as a substring
|
||||
// after stripping the "+"/"--" sigils. It catches hallucinated names that drop
|
||||
// a namespace prefix (e.g. "+block-list" for "+base-block-list"), which share
|
||||
// almost no prefix and sit far beyond the edit-distance budget. The shorter
|
||||
// side must be at least 5 runes so generic fragments like "list" do not match
|
||||
// half the catalog.
|
||||
func containsSegment(a, b string) bool {
|
||||
a = strings.TrimLeft(a, "+-")
|
||||
b = strings.TrimLeft(b, "+-")
|
||||
if len([]rune(a)) > len([]rune(b)) {
|
||||
a, b = b, a
|
||||
}
|
||||
return len([]rune(a)) >= 5 && strings.Contains(b, a)
|
||||
}
|
||||
|
||||
func sharedPrefixLen(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
n := 0
|
||||
|
||||
@@ -15,15 +15,28 @@ import (
|
||||
// legacy validation/save helpers are forbidden; callers must use the typed
|
||||
// common replacements or construct an errs.* typed error directly.
|
||||
var migratedCommonHelperPaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/apps/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/note/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/wiki/",
|
||||
}
|
||||
|
||||
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
@@ -16,16 +16,28 @@ import (
|
||||
// call sites must return a typed errs.* error instead. Future domains opt in by
|
||||
// appending their path prefix here.
|
||||
var migratedEnvelopePaths = []string{
|
||||
"cmd/event/",
|
||||
"events/",
|
||||
"internal/event/consume/",
|
||||
"shortcuts/apps/",
|
||||
"shortcuts/base/",
|
||||
"shortcuts/calendar/",
|
||||
"shortcuts/contact/",
|
||||
"shortcuts/doc/",
|
||||
"shortcuts/drive/",
|
||||
"shortcuts/event/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/mail/",
|
||||
"shortcuts/markdown/",
|
||||
"shortcuts/minutes/",
|
||||
"shortcuts/note/",
|
||||
"shortcuts/okr/",
|
||||
"shortcuts/sheets/",
|
||||
"shortcuts/slides/",
|
||||
"shortcuts/task/",
|
||||
"shortcuts/vc/",
|
||||
"shortcuts/whiteboard/",
|
||||
"shortcuts/im/",
|
||||
"shortcuts/wiki/",
|
||||
}
|
||||
|
||||
// legacyOutputImportPath is the import path of the package that declares the
|
||||
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
|
||||
// they return the raw response for the caller to classify and do not emit a
|
||||
// legacy envelope themselves.
|
||||
//
|
||||
// Files that do not import shortcuts/common are skipped: the legacy helpers
|
||||
// are methods on common.RuntimeContext, so a same-named method on another
|
||||
// receiver (for example the event domain's APIClient interface, whose
|
||||
// implementation classifies into typed errs.* errors) is not a legacy call.
|
||||
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if !isMigratedEnvelopePath(path) || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
@@ -36,6 +41,9 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !importsPath(file, commonImportPath) {
|
||||
return nil
|
||||
}
|
||||
var out []Violation
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
@@ -71,3 +79,16 @@ func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// importsPath reports whether the file imports the given package path.
|
||||
func importsPath(file *ast.File, importPath string) bool {
|
||||
for _, imp := range file.Imports {
|
||||
if imp.Path == nil {
|
||||
continue
|
||||
}
|
||||
if strings.Trim(imp.Path.Value, "`\"") == importPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -620,6 +620,7 @@ func boom() error {
|
||||
|
||||
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"shortcuts/markdown/markdown_fetch.go",
|
||||
"shortcuts/okr/okr_image_upload.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_update.go",
|
||||
@@ -691,7 +692,7 @@ func boom() error {
|
||||
return &output.ExitError{Code: 1}
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
|
||||
v := CheckNoLegacyEnvelopeLiteral("shortcuts/unmigrated/foo.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path should pass, got: %+v", v)
|
||||
}
|
||||
@@ -813,6 +814,8 @@ func boom() error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -833,6 +836,8 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
|
||||
src := `package task
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -853,6 +858,8 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
||||
src := `package drive
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom(runtime *common.RuntimeContext) error {
|
||||
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
|
||||
return err
|
||||
@@ -907,7 +914,7 @@ func boom(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
|
||||
v := CheckNoLegacyRuntimeAPICall("shortcuts/unmigrated/sample.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must not fire, got: %+v", v)
|
||||
}
|
||||
@@ -944,11 +951,17 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
|
||||
"HandleApiResult",
|
||||
}
|
||||
paths := []string{
|
||||
"shortcuts/doc/docs_fetch_v2.go",
|
||||
"shortcuts/drive/drive_search.go",
|
||||
"shortcuts/im/im_messages_send.go",
|
||||
"shortcuts/mail/mail_send.go",
|
||||
"shortcuts/markdown/markdown_fetch.go",
|
||||
"shortcuts/okr/okr_progress_create.go",
|
||||
"shortcuts/sheets/helpers.go",
|
||||
"shortcuts/slides/slides_create.go",
|
||||
"shortcuts/task/task_update.go",
|
||||
"shortcuts/whiteboard/whiteboard_query.go",
|
||||
"shortcuts/wiki/wiki_node_get.go",
|
||||
}
|
||||
for _, path := range paths {
|
||||
for _, helper := range helpers {
|
||||
@@ -976,6 +989,18 @@ common.` + helper + `()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) {
|
||||
commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths))
|
||||
for _, path := range migratedCommonHelperPaths {
|
||||
commonPaths[path] = struct{}{}
|
||||
}
|
||||
for _, path := range migratedEnvelopePaths {
|
||||
if _, ok := commonPaths[path]; !ok {
|
||||
t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
|
||||
src := `package calendar
|
||||
|
||||
@@ -997,6 +1022,91 @@ func boom() {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversDocPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/doc/docs_fetch_v2.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversSheetsPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/sheets/helpers.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on sheets path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversSlidesPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/slides/slides_create.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on slides path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversMarkdownPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/markdown/markdown_fetch.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on markdown path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_CoversWikiPathWithAliasAndFunctionValue(t *testing.T) {
|
||||
src := `package migrated
|
||||
|
||||
import c "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
func boom() {
|
||||
f := c.FlagErrorf
|
||||
_ = f
|
||||
c.WrapInputStatError(nil)
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/wiki/wiki_node_get.go", src)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on wiki path, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
|
||||
src := `package contact
|
||||
|
||||
@@ -1006,7 +1116,7 @@ func boom() {
|
||||
common.FlagErrorf("legacy allowed until domain migrates")
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.go", src)
|
||||
v := CheckNoLegacyCommonHelperCall("shortcuts/unmigrated/sample.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-migrated path must pass, got: %+v", v)
|
||||
}
|
||||
@@ -1076,3 +1186,23 @@ func boom() error {
|
||||
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoLegacyRuntimeAPICall_SkipsNonCommonReceiver(t *testing.T) {
|
||||
// The event domain's APIClient interface has a same-named CallAPI method
|
||||
// whose implementation classifies into typed errs.* errors; without the
|
||||
// shortcuts/common import the call cannot be the legacy RuntimeContext
|
||||
// helper and must not fire.
|
||||
src := `package vc
|
||||
|
||||
import "github.com/larksuite/cli/internal/event"
|
||||
|
||||
func boom(rt event.APIClient) error {
|
||||
_, err := rt.CallAPI(nil, "POST", "/x", nil)
|
||||
return err
|
||||
}
|
||||
`
|
||||
v := CheckNoLegacyRuntimeAPICall("events/vc/preconsume.go", src)
|
||||
if len(v) != 0 {
|
||||
t.Errorf("non-common CallAPI receiver must not fire, got: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.48",
|
||||
"version": "1.0.53",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
@@ -15,7 +15,8 @@
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
"arm64",
|
||||
"riscv64"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
|
||||
@@ -33,6 +33,7 @@ build_target darwin arm64
|
||||
build_target linux amd64
|
||||
build_target darwin amd64
|
||||
build_target linux arm64
|
||||
build_target linux riscv64
|
||||
build_target windows amd64
|
||||
build_target windows arm64
|
||||
|
||||
@@ -55,6 +56,7 @@ const platformMap = {
|
||||
const archMap = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
riscv64: "riscv64",
|
||||
};
|
||||
|
||||
const platform = platformMap[process.platform];
|
||||
|
||||
@@ -30,6 +30,7 @@ const PLATFORM_MAP = {
|
||||
const ARCH_MAP = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
riscv64: "riscv64",
|
||||
};
|
||||
|
||||
const platform = PLATFORM_MAP[process.platform];
|
||||
|
||||
@@ -9,27 +9,29 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsAccessScopeGet reads the current access scope configuration of a Miaoda app.
|
||||
// AppsAccessScopeGet reads the current access scope configuration of an app.
|
||||
// 响应原样透传服务端契约(字符串 scope 枚举 All/Tenant/Range + 拆分的 users/departments/chats 数组)。
|
||||
var AppsAccessScopeGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+access-scope-get",
|
||||
Description: "Get Miaoda app access scope configuration",
|
||||
Description: "Get app access scope configuration",
|
||||
Risk: "read",
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +access-scope-get --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return output.ErrValidation("--app-id is required")
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -37,14 +39,14 @@ var AppsAccessScopeGet = common.Shortcut{
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Get Miaoda app access scope")
|
||||
Desc("Get app access scope")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPI("GET", path, nil, nil)
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||
}
|
||||
// 原样透传 — 保留服务端字符串枚举 (All/Tenant/Range),不合并 users/departments/chats。
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user