mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 23:15:25 +08:00
Compare commits
16 Commits
feat/app_r
...
feat/batch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7c7f9f390 | ||
|
|
3f993ea772 | ||
|
|
461b4a7e80 | ||
|
|
d6b235aaa2 | ||
|
|
d6dfd1e043 | ||
|
|
3a33794aec | ||
|
|
d11a6e97a4 | ||
|
|
e4248d1154 | ||
|
|
cb54bea00d | ||
|
|
036e5799d3 | ||
|
|
c4106f50b2 | ||
|
|
736b131cdf | ||
|
|
5efaf65aec | ||
|
|
0991da7446 | ||
|
|
80bea45c6a | ||
|
|
c775cb4360 |
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
@@ -9,11 +9,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# All platforms (incl. darwin keychain_signer) are CGO-free and cross-compiled
|
||||
# on a single ubuntu runner in one goreleaser run (one checksums.txt). The
|
||||
# darwin signer's runtime FFI is validated separately by the signer-test job.
|
||||
goreleaser:
|
||||
needs: signer-test-macos
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -38,21 +34,6 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Validate the macOS keychain signer on real hardware. The release binaries are
|
||||
# cross-compiled on ubuntu (CGO-free purego FFI), so this is the only step that
|
||||
# needs a Mac — and it gates the release rather than producing it.
|
||||
signer-test-macos:
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
- name: Keychain signer round-trip (CGO-free purego FFI)
|
||||
run: LARK_KEYCHAIN_IT=1 CGO_ENABLED=0 go test -tags keychain_signer -run Keychain -v ./internal/keysigner/
|
||||
|
||||
publish-npm:
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
30
.github/workflows/semantic-review.yml
vendored
30
.github/workflows/semantic-review.yml
vendored
@@ -47,10 +47,13 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -71,11 +74,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -123,7 +126,7 @@ jobs:
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||
@@ -255,10 +258,13 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -279,11 +285,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -331,7 +337,7 @@ jobs:
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR base");
|
||||
|
||||
@@ -5,53 +5,15 @@ before:
|
||||
- python3 scripts/fetch_meta.py
|
||||
|
||||
builds:
|
||||
# Linux & Windows: pure-Go TPM 2.0 signer is compiled in by default (no build
|
||||
# tag), cross-compiled with CGO disabled — the binaries ship the platform key
|
||||
# signer for private_key_jwt. windows/arm64 is the one exception: the sks
|
||||
# Windows dependency stack (go-ole) has no arm64 support, so the signer file is
|
||||
# arch-excluded there and that binary falls back to client_secret only.
|
||||
- id: linux
|
||||
binary: lark-cli
|
||||
main: .
|
||||
- binary: lark-cli
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- id: windows
|
||||
binary: lark-cli
|
||||
main: .
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
|
||||
goos:
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
# macOS: the keychain signer calls Security.framework via runtime FFI (purego),
|
||||
# so it is CGO-free, compiled into every darwin build (no build tag), and
|
||||
# cross-compiles from the same ubuntu runner as linux/windows.
|
||||
- id: darwin
|
||||
binary: lark-cli
|
||||
main: .
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
@@ -61,7 +23,7 @@ archives:
|
||||
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
format: zip
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,6 +2,30 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||
- **base**: Support record comments (#1043)
|
||||
- **search**: Surface search API notices (#1413)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||
- **mail**: Trim lark-mail skill context (#1527)
|
||||
- **drive**: Add permission governance workflow guidance (#1292)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||
|
||||
## [v1.0.56] - 2026-06-18
|
||||
|
||||
### Features
|
||||
@@ -1212,6 +1236,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
|
||||
6
Makefile
6
Makefile
@@ -33,11 +33,7 @@ build: fetch_meta
|
||||
go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||
|
||||
vet: fetch_meta
|
||||
# -unsafeptr=false: the macOS keychain signer dereferences dylib data-symbol
|
||||
# addresses from purego.Dlsym (uintptr->unsafe.Pointer over stable C memory) —
|
||||
# safe FFI, but go vet's unsafeptr can't prove it and has no inline suppress.
|
||||
# golangci-lint still runs full govet (honoring the //nolint:govet) in CI.
|
||||
go vet -unsafeptr=false ./...
|
||||
go vet ./...
|
||||
|
||||
# fmt-check fails when any file would be reformatted by gofmt. Keep this
|
||||
# in sync with the fast-gate "Check formatting" step in CI.
|
||||
|
||||
@@ -265,7 +265,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authResp, err := larkauth.RequestDeviceAuthorization(opts.Ctx, httpClient, larkauth.ClientAuthFromConfig(config), config.Brand, finalScope, f.IOStreams.ErrOut)
|
||||
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
|
||||
}
|
||||
@@ -325,7 +325,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// Step 3: Poll for token
|
||||
log(msg.WaitingAuth)
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, larkauth.ClientAuthFromConfig(config), config.Brand,
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
@@ -415,7 +415,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, larkauth.ClientAuthFromConfig(config), config.Brand,
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
|
||||
@@ -260,6 +260,15 @@ func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_SlidesDoesNotAdvertiseScreenshotScope(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"slides"}, "user", "")
|
||||
for _, scope := range scopes {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("slides domain scopes must not advertise allowlist-gated screenshot scope: %#v", scopes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
nameSet := make(map[string]bool)
|
||||
@@ -847,7 +856,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
|
||||
}
|
||||
|
||||
@@ -886,7 +895,7 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
|
||||
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, "", "", nil); err != nil {
|
||||
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
|
||||
t.Fatalf("saveInitConfig (no --lang): %v", err)
|
||||
}
|
||||
|
||||
@@ -206,88 +206,6 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyRefFromResult_PrivateKeyJWT(t *testing.T) {
|
||||
ref := keyRefFromResult(&configInitResult{
|
||||
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
||||
KeyLabel: "lark-cli-default",
|
||||
})
|
||||
if ref == nil {
|
||||
t.Fatal("keyRefFromResult returned nil")
|
||||
}
|
||||
if ref.Source != "tee" || ref.ID != "lark-cli-default" {
|
||||
t.Fatalf("key ref = %#v, want tee/lark-cli-default", ref)
|
||||
}
|
||||
|
||||
if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodPrivateKeyJWT}); ref != nil {
|
||||
t.Fatalf("missing key label should not persist key ref, got %#v", ref)
|
||||
}
|
||||
if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodClientSecret, KeyLabel: "ignored"}); ref != nil {
|
||||
t.Fatalf("client_secret should not persist key ref, got %#v", ref)
|
||||
}
|
||||
if ref := keyRefFromResult(nil); ref != nil {
|
||||
t.Fatalf("nil result should not persist key ref, got %#v", ref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveInitConfig_PrivateKeyJWTSingleAppPersistsSecretlessAuth(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
|
||||
if err := saveInitConfig("", nil, f, "cli_pkjwt", core.SecretInput{}, core.BrandFeishu, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
|
||||
t.Fatalf("saveInitConfig private_key_jwt single app: %v", err)
|
||||
}
|
||||
|
||||
got, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
if len(got.Apps) != 1 {
|
||||
t.Fatalf("apps len = %d, want 1", len(got.Apps))
|
||||
}
|
||||
app := got.Apps[0]
|
||||
if app.AppId != "cli_pkjwt" {
|
||||
t.Fatalf("AppId = %q, want cli_pkjwt", app.AppId)
|
||||
}
|
||||
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
|
||||
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
|
||||
}
|
||||
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
|
||||
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
|
||||
}
|
||||
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
|
||||
t.Fatalf("private_key_jwt config must stay secretless, AppSecret=%#v", app.AppSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveInitConfig_PrivateKeyJWTProfilePersistsSecretlessAuth(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
|
||||
if err := saveInitConfig("prod", &core.MultiAppConfig{}, f, "cli_pkjwt", core.SecretInput{}, core.BrandLark, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
|
||||
t.Fatalf("saveInitConfig private_key_jwt profile: %v", err)
|
||||
}
|
||||
|
||||
got, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
app := got.FindApp("prod")
|
||||
if app == nil {
|
||||
t.Fatalf("profile prod not saved: %#v", got.Apps)
|
||||
}
|
||||
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
|
||||
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
|
||||
}
|
||||
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
|
||||
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
|
||||
}
|
||||
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
|
||||
t.Fatalf("private_key_jwt profile must stay secretless, AppSecret=%#v", app.AppSecret)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
|
||||
// strictly validated the same way bind validates: wrong-case / typo / removed
|
||||
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
|
||||
@@ -470,7 +388,7 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
|
||||
},
|
||||
}
|
||||
|
||||
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en", "", nil)
|
||||
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
@@ -509,46 +427,6 @@ func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAsProfile_UpdatePersistsPrivateKeyJWT(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "prod",
|
||||
AppId: "cli_prod",
|
||||
AppSecret: core.PlainSecret("old-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
|
||||
}},
|
||||
}
|
||||
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
|
||||
|
||||
if err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "prod", "cli_prod", core.SecretInput{}, core.BrandLark, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
|
||||
t.Fatalf("saveAsProfile update private_key_jwt: %v", err)
|
||||
}
|
||||
|
||||
got, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig: %v", err)
|
||||
}
|
||||
app := got.FindApp("prod")
|
||||
if app == nil {
|
||||
t.Fatalf("profile prod not saved: %#v", got.Apps)
|
||||
}
|
||||
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
|
||||
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
|
||||
}
|
||||
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
|
||||
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
|
||||
}
|
||||
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
|
||||
t.Fatalf("private_key_jwt update must stay secretless, AppSecret=%#v", app.AppSecret)
|
||||
}
|
||||
if len(app.Users) != 1 || app.Users[0].UserOpenId != "ou_1" {
|
||||
t.Fatalf("same-app update should preserve users, Users=%#v", app.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "prod",
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -32,7 +31,6 @@ type ConfigInitOptions struct {
|
||||
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
|
||||
Brand string
|
||||
New bool
|
||||
AuthMethod string // --auth-method for --new: "" (default client_secret) | private_key_jwt
|
||||
|
||||
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
|
||||
langExplicit bool // true when --lang was explicitly passed
|
||||
@@ -41,8 +39,6 @@ type ConfigInitOptions struct {
|
||||
|
||||
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
||||
|
||||
Restore bool // Restore re-registers the app already in config to recover a lost credential
|
||||
|
||||
// ForceInit overrides the agent-workspace guard. Without it, running
|
||||
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
||||
// at config bind — which is what AI agents almost always want. Manual
|
||||
@@ -85,13 +81,11 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)")
|
||||
cmd.Flags().StringVar(&opts.AuthMethod, "auth-method", "", "auth method for --new: client_secret (default) or private_key_jwt (signed by a platform key, no app secret)")
|
||||
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
|
||||
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
|
||||
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
cmd.Flags().BoolVar(&opts.Restore, "restore", false, "re-register the app already in config to recover a lost credential (keychain key / app secret); reuses the stored app ID and auth method")
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
@@ -138,7 +132,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
|
||||
return o.New || o.Restore || o.AppID != "" || o.AppSecretStdin
|
||||
return o.New || o.AppID != "" || o.AppSecretStdin
|
||||
}
|
||||
|
||||
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
|
||||
@@ -157,44 +151,11 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
|
||||
}
|
||||
}
|
||||
|
||||
// removeStaleSecretForPKJWT clears a secret left in the keychain when the SAME
|
||||
// appId is migrated from client_secret to private_key_jwt. cleanupOldConfig
|
||||
// explicitly skips a matching appId, and saveAsProfile only cleans up on an
|
||||
// appId change, so a same-appId migration would orphan the old secret. This
|
||||
// fills that gap. RemoveSecretStore only deletes Source=="keychain" entries, so
|
||||
// the new pkjwt tee key handle is never touched.
|
||||
func removeStaleSecretForPKJWT(existing *core.MultiAppConfig, profileName, appID string, kc keychain.KeychainAccess) {
|
||||
if existing == nil {
|
||||
return
|
||||
}
|
||||
var prior *core.AppConfig
|
||||
if profileName != "" {
|
||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||
prior = &existing.Apps[idx]
|
||||
}
|
||||
} else {
|
||||
prior = existing.CurrentAppConfig("")
|
||||
}
|
||||
if prior != nil && prior.AppId == appID && !prior.AppSecret.IsZero() {
|
||||
core.RemoveSecretStore(prior.AppSecret, kc)
|
||||
}
|
||||
}
|
||||
|
||||
// keyRefFromResult builds the TEE key reference to persist for a private_key_jwt
|
||||
// registration result, or nil for client_secret.
|
||||
func keyRefFromResult(r *configInitResult) *core.SecretRef {
|
||||
if r != nil && r.AuthMethod == core.AuthMethodPrivateKeyJWT && r.KeyLabel != "" {
|
||||
return &core.SecretRef{Source: "tee", ID: r.KeyLabel}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveAsOnlyApp overwrites config.json with a single-app config.
|
||||
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
|
||||
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
config := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
|
||||
AuthMethod: authMethod, KeyRef: keyRef,
|
||||
}},
|
||||
}
|
||||
return core.SaveMultiAppConfig(config)
|
||||
@@ -203,11 +164,9 @@ func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand,
|
||||
// saveInitConfig saves a new/updated app config, respecting --profile mode.
|
||||
// With profileName: appends or updates the named profile (preserves other profiles).
|
||||
// Without profileName: cleans up old config and saves as the only app.
|
||||
// authMethod/keyRef carry the credential type: ("", nil) for client_secret,
|
||||
// (private_key_jwt, &{tee,label}) for the secretless TEE flow.
|
||||
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
|
||||
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
if profileName != "" {
|
||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang, authMethod, keyRef)
|
||||
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
||||
}
|
||||
cleanupOldConfig(existing, f, appId)
|
||||
var prior i18n.Lang
|
||||
@@ -216,7 +175,7 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
|
||||
prior = app.Lang
|
||||
}
|
||||
}
|
||||
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)), authMethod, keyRef)
|
||||
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
|
||||
}
|
||||
|
||||
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
|
||||
@@ -236,7 +195,7 @@ func wrapSaveConfigError(err error) error {
|
||||
// saveAsProfile appends or updates a named profile in the config.
|
||||
// If a profile with the same name exists, it updates it; otherwise appends.
|
||||
// When updating, cleans up old keychain secrets if AppId changed.
|
||||
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
|
||||
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
||||
multi := existing
|
||||
if multi == nil {
|
||||
multi = &core.MultiAppConfig{}
|
||||
@@ -255,8 +214,6 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
||||
multi.Apps[idx].AppSecret = secret
|
||||
multi.Apps[idx].Brand = brand
|
||||
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
|
||||
multi.Apps[idx].AuthMethod = authMethod
|
||||
multi.Apps[idx].KeyRef = keyRef
|
||||
} else {
|
||||
if findAppIndexByAppID(multi, profileName) >= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
@@ -265,14 +222,12 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
|
||||
}
|
||||
// Append new profile
|
||||
multi.Apps = append(multi.Apps, core.AppConfig{
|
||||
Name: profileName,
|
||||
AppId: appId,
|
||||
AppSecret: secret,
|
||||
Brand: brand,
|
||||
Lang: i18n.Lang(lang),
|
||||
Users: []core.AppUser{},
|
||||
AuthMethod: authMethod,
|
||||
KeyRef: keyRef,
|
||||
Name: profileName,
|
||||
AppId: appId,
|
||||
AppSecret: secret,
|
||||
Brand: brand,
|
||||
Lang: i18n.Lang(lang),
|
||||
Users: []core.AppUser{},
|
||||
})
|
||||
}
|
||||
return core.SaveMultiAppConfig(multi)
|
||||
@@ -350,94 +305,6 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
|
||||
return core.SaveMultiAppConfig(existing)
|
||||
}
|
||||
|
||||
// persistAndProbeResult saves a registration/restore result into profileName and
|
||||
// runs the post-registration probe. profileName == "" replaces the single app
|
||||
// (legacy); a named profile is updated in place. Shared by --new and --restore.
|
||||
func persistAndProbeResult(opts *ConfigInitOptions, f *cmdutil.Factory, profileName string, result *configInitResult) error {
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
|
||||
// private_key_jwt apps have no secret: persist auth method + TEE key ref.
|
||||
// Registration success already validated the key (server bound the public
|
||||
// key), so the app_secret probe is skipped.
|
||||
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
|
||||
if err := saveInitConfig(profileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
removeStaleSecretForPKJWT(existing, profileName, result.AppID, f.Keychain)
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "authMethod": result.AuthMethod, "brand": result.Brand})
|
||||
return runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel)
|
||||
}
|
||||
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(profileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
return runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand)
|
||||
}
|
||||
|
||||
// runRestoreFlow re-registers the app already in config to recover a lost
|
||||
// credential (deleted keychain key / lost app secret). It reads the existing
|
||||
// app id + auth method + brand from config (no secret needed — that's the lost
|
||||
// part) and re-runs the device-flow registration with the app id sent on begin,
|
||||
// so the server re-registers that app instead of creating a new one. The
|
||||
// re-issued credential is written back to the same profile.
|
||||
func runRestoreFlow(opts *ConfigInitOptions, existing *core.MultiAppConfig, f *cmdutil.Factory, msg *initMsg) error {
|
||||
if existing == nil {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no config found").
|
||||
WithHint("run: lark-cli config init")
|
||||
}
|
||||
app := existing.CurrentAppConfig(opts.ProfileName)
|
||||
if app == nil || app.AppId == "" {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no app id in config%s", profileSuffix(opts.ProfileName)).
|
||||
WithHint("run: lark-cli config init")
|
||||
}
|
||||
|
||||
restoreAppID := app.AppId
|
||||
// Reuse the stored auth method authoritatively — never prompt. Empty on disk
|
||||
// means client_secret (omitempty back-compat); pass it explicitly so
|
||||
// resolveRegisterAuthMethod doesn't fall through to the interactive picker.
|
||||
authMethod := app.AuthMethod
|
||||
if authMethod == "" {
|
||||
authMethod = core.AuthMethodClientSecret
|
||||
}
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, app.Brand, authMethod, msg, restoreAppID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "app restore returned no result")
|
||||
}
|
||||
|
||||
// Safety: if the server did not honor app_id (e.g. not yet supported), it may
|
||||
// have created a NEW app instead of restoring. Warn so the user is not silently
|
||||
// switched to a different app id.
|
||||
if result.AppID != restoreAppID {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] restore: server returned app %s, expected %s — it may have created a new app instead of restoring\n", result.AppID, restoreAppID)
|
||||
}
|
||||
|
||||
// Write back to the profile we restored: an explicit --name, else the resolved
|
||||
// app's own name. Empty name => legacy single-app replace.
|
||||
saveProfile := opts.ProfileName
|
||||
if saveProfile == "" {
|
||||
saveProfile = app.Name
|
||||
}
|
||||
return persistAndProbeResult(opts, f, saveProfile, result)
|
||||
}
|
||||
|
||||
// profileSuffix renders " (profile %q)" for error messages, or "" when unnamed.
|
||||
func profileSuffix(profileName string) string {
|
||||
if profileName == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" (profile %q)", profileName)
|
||||
}
|
||||
|
||||
func configInitRun(opts *ConfigInitOptions) error {
|
||||
f := opts.Factory
|
||||
|
||||
@@ -468,17 +335,6 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
// --restore recovers an existing app; it is incompatible with creating a new
|
||||
// app (--new) or importing one non-interactively (--app-id / stdin secret).
|
||||
if opts.Restore {
|
||||
if opts.New {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --new").WithParam("--restore")
|
||||
}
|
||||
if opts.AppID != "" || opts.AppSecretStdin {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --app-id / --app-secret-stdin").WithParam("--restore")
|
||||
}
|
||||
}
|
||||
|
||||
// Mode 1: Non-interactive
|
||||
if opts.AppID != "" && opts.appSecret != "" {
|
||||
brand := parseBrand(opts.Brand)
|
||||
@@ -486,7 +342,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang, "", nil); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
@@ -512,26 +368,34 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
msg := getInitMsg(opts.UILang)
|
||||
|
||||
// Mode: Restore (--restore) — re-register the app already in config.
|
||||
if opts.Restore {
|
||||
return runRestoreFlow(opts, existing, f, msg)
|
||||
}
|
||||
|
||||
// Mode 3: Create new app directly (--new)
|
||||
if opts.New {
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), opts.AuthMethod, msg, "")
|
||||
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
|
||||
}
|
||||
return persistAndProbeResult(opts, f, opts.ProfileName, result)
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mode 4: Interactive TUI (terminal)
|
||||
if !opts.hasAnyNonInteractiveFlag() && f.IOStreams.IsTerminal {
|
||||
result, err := runInteractiveConfigInit(opts.Ctx, f, opts.AuthMethod, msg)
|
||||
result, err := runInteractiveConfigInit(opts.Ctx, f, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -542,22 +406,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
|
||||
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
|
||||
// Secretless create: persist auth method + TEE key ref, no secret.
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
removeStaleSecretForPKJWT(existing, opts.ProfileName, result.AppID, f.Keychain)
|
||||
if err := runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if result.AppSecret != "" {
|
||||
if result.AppSecret != "" {
|
||||
// New secret provided (either from "create" or "existing" with input)
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
@@ -662,7 +517,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang, "", nil); err != nil {
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
return wrapSaveConfigError(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
type authMethodTestSigner struct{}
|
||||
|
||||
func (authMethodTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (authMethodTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (authMethodTestSigner) Sign(context.Context, keysigner.KeyRef, []byte) ([]byte, string, error) {
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
// TestResolveRegisterAuthMethod covers the non-interactive gating paths. The
|
||||
// darwin keychain signer is compiled into every build, so the test cannot rely
|
||||
// on the binary lacking a signer — it forces a known no-signer state for the
|
||||
// rejection cases, then registers a stub for the success case.
|
||||
func TestResolveRegisterAuthMethod(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
|
||||
prevSigner := keysigner.Active()
|
||||
t.Cleanup(func() { keysigner.Register(prevSigner) })
|
||||
keysigner.Register(nil)
|
||||
|
||||
if m, err := resolveRegisterAuthMethod(f, core.AuthMethodClientSecret); err != nil || m != core.AuthMethodClientSecret {
|
||||
t.Errorf("client_secret: got (%q, %v), want (client_secret, nil)", m, err)
|
||||
}
|
||||
|
||||
if m, err := resolveRegisterAuthMethod(f, ""); err != nil || m != core.AuthMethodClientSecret {
|
||||
t.Errorf("default: got (%q, %v), want (client_secret, nil)", m, err)
|
||||
}
|
||||
|
||||
if _, err := resolveRegisterAuthMethod(f, "bogus"); err == nil {
|
||||
t.Error("bogus auth-method: expected error")
|
||||
}
|
||||
|
||||
if _, err := resolveRegisterAuthMethod(f, core.AuthMethodPrivateKeyJWT); err == nil {
|
||||
t.Error("private_key_jwt without a signer: expected error")
|
||||
}
|
||||
|
||||
keysigner.Register(authMethodTestSigner{})
|
||||
|
||||
if m, err := resolveRegisterAuthMethod(f, core.AuthMethodPrivateKeyJWT); err != nil || m != core.AuthMethodPrivateKeyJWT {
|
||||
t.Errorf("private_key_jwt with signer: got (%q, %v), want (private_key_jwt, nil)", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidatePKJWTKeyBinding covers the guard that rejects a registration
|
||||
// resolving to private_key_jwt with no signing key bound (e.g. an existing
|
||||
// secret-based app was selected on the confirm page).
|
||||
func TestValidatePKJWTKeyBinding(t *testing.T) {
|
||||
if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, ""); err == nil {
|
||||
t.Error("pkjwt with empty keyLabel: expected error")
|
||||
}
|
||||
if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, "agent-key"); err != nil {
|
||||
t.Errorf("pkjwt with keyLabel: expected nil, got %v", err)
|
||||
}
|
||||
if err := validatePKJWTKeyBinding(core.AuthMethodClientSecret, ""); err != nil {
|
||||
t.Errorf("client_secret: expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveFinalAuthMethod locks the authoritative-method logic. The 2nd case
|
||||
// is the real bug: we requested private_key_jwt but the server resolved to an
|
||||
// existing client_secret app — we must persist client_secret, not pkjwt.
|
||||
func TestResolveFinalAuthMethod(t *testing.T) {
|
||||
if m := resolveFinalAuthMethod([]string{"client_secret", "private_key_jwt"}, core.AuthMethodClientSecret); m != core.AuthMethodPrivateKeyJWT {
|
||||
t.Errorf("prefers private_key_jwt: got %q", m)
|
||||
}
|
||||
if m := resolveFinalAuthMethod([]string{"client_secret"}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodClientSecret {
|
||||
t.Errorf("server client_secret must override requested pkjwt: got %q", m)
|
||||
}
|
||||
if m := resolveFinalAuthMethod(nil, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT {
|
||||
t.Errorf("fallback to requested when server is silent: got %q", m)
|
||||
}
|
||||
// Explicit empty slice (not just nil) also falls back to requested — the same
|
||||
// len()==0 back-compat allowance the init guard relies on to let private_key_jwt
|
||||
// proceed against an older server (see internal/auth
|
||||
// TestRequestAppRegistrationInit_EmptySupportedAuthMethods).
|
||||
if m := resolveFinalAuthMethod([]string{}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT {
|
||||
t.Errorf("empty []string should fall back to requested private_key_jwt: got %q", m)
|
||||
}
|
||||
if m := resolveFinalAuthMethod(nil, ""); m != core.AuthMethodClientSecret {
|
||||
t.Errorf("default to client_secret: got %q", m)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,7 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -17,26 +13,22 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/auth/jwt"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// configInitResult holds the result of the interactive config init flow.
|
||||
type configInitResult struct {
|
||||
Mode string // "create" or "existing"
|
||||
Brand core.LarkBrand
|
||||
AppID string
|
||||
AppSecret string
|
||||
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
|
||||
KeyLabel string // TEE key handle when AuthMethod == private_key_jwt
|
||||
Mode string // "create" or "existing"
|
||||
Brand core.LarkBrand
|
||||
AppID string
|
||||
AppSecret string
|
||||
}
|
||||
|
||||
// runInteractiveConfigInit shows an interactive TUI for config init.
|
||||
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMethodFlag string, msg *initMsg) (*configInitResult, error) {
|
||||
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
|
||||
// Phase 1: Choose mode
|
||||
var mode string
|
||||
form1 := huh.NewForm(
|
||||
@@ -62,7 +54,7 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMetho
|
||||
return runExistingAppForm(f, msg)
|
||||
}
|
||||
|
||||
return runCreateAppFlow(ctx, f, "", authMethodFlag, msg, "")
|
||||
return runCreateAppFlow(ctx, f, "", msg)
|
||||
}
|
||||
|
||||
// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
|
||||
@@ -154,59 +146,9 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveRegisterAuthMethod decides the auth method for a new-app registration.
|
||||
// An explicit --auth-method flag wins; otherwise, on an interactive terminal with
|
||||
// a TEE signer available, the user is prompted; the default is client_secret.
|
||||
func resolveRegisterAuthMethod(f *cmdutil.Factory, flag string) (string, error) {
|
||||
signerAvailable := keysigner.Active() != nil
|
||||
switch flag {
|
||||
case core.AuthMethodPrivateKeyJWT:
|
||||
if !signerAvailable {
|
||||
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
|
||||
"--auth-method private_key_jwt requires a platform key signer, which is unavailable on this device/build").
|
||||
WithHint("omit --auth-method (or pass --auth-method client_secret) to register with an app secret")
|
||||
}
|
||||
return core.AuthMethodPrivateKeyJWT, nil
|
||||
case core.AuthMethodClientSecret:
|
||||
return core.AuthMethodClientSecret, nil
|
||||
case "":
|
||||
// fall through to interactive / default
|
||||
default:
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown --auth-method %q (use client_secret or private_key_jwt)", flag)
|
||||
}
|
||||
|
||||
if signerAvailable && f.IOStreams.IsTerminal {
|
||||
var choice string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Authentication method").
|
||||
Options(
|
||||
huh.NewOption("App Secret (client_secret)", core.AuthMethodClientSecret),
|
||||
huh.NewOption("Secure key signer, no secret (private_key_jwt)", core.AuthMethodPrivateKeyJWT),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
).WithTheme(cmdutil.ThemeFeishu())
|
||||
if err := form.Run(); err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return "", output.ErrBare(1)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
return core.AuthMethodClientSecret, nil
|
||||
}
|
||||
|
||||
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
|
||||
// If brandOverride is non-empty, skip the interactive brand selection.
|
||||
// authMethodFlag is the raw --auth-method value ("" when unset).
|
||||
// restoreAppID, when non-empty, is sent on the registration begin request so the
|
||||
// server re-registers that existing app (credential recovery) instead of creating
|
||||
// a new one. Empty preserves the normal new-app flow.
|
||||
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, authMethodFlag string, msg *initMsg, restoreAppID string) (*configInitResult, error) {
|
||||
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
|
||||
var larkBrand core.LarkBrand
|
||||
if brandOverride != "" {
|
||||
larkBrand = brandOverride
|
||||
@@ -234,51 +176,11 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
larkBrand = parseBrand(brand)
|
||||
}
|
||||
|
||||
authMethod, err := resolveRegisterAuthMethod(f, authMethodFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 1: Request app registration (begin).
|
||||
// Step 1: Request app registration (begin)
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
|
||||
// For private_key_jwt: init to obtain a nonce, then sign a TEE attestation
|
||||
// (carrying the public key in its jwk header) to send with begin.
|
||||
beginOpts := larkauth.AppRegistrationBeginOptions{}
|
||||
keyLabel := ""
|
||||
if authMethod == core.AuthMethodPrivateKeyJWT {
|
||||
signer := keysigner.Active() // non-nil, guaranteed by resolveRegisterAuthMethod
|
||||
initResp, initErr := larkauth.RequestAppRegistrationInit(httpClient)
|
||||
if initErr != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration init failed: %v", initErr).WithCause(initErr)
|
||||
}
|
||||
// An empty SupportedAuthMethods is intentionally treated as "older server /
|
||||
// unknown": len()==0 makes this guard false, so the requested
|
||||
// private_key_jwt proceeds. This mirrors resolveFinalAuthMethod's
|
||||
// back-compat fallback to the requested method. Only an explicit list that
|
||||
// omits private_key_jwt rejects here.
|
||||
if len(initResp.SupportedAuthMethods) > 0 && !slices.Contains(initResp.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT) {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient,
|
||||
"server does not support private_key_jwt for this app type (supported: %s)", strings.Join(initResp.SupportedAuthMethods, ", ")).
|
||||
WithHint("register with --auth-method client_secret instead")
|
||||
}
|
||||
keyLabel = keysigner.DefaultKeyLabel
|
||||
attestation, signErr := jwt.SignAttestation(ctx, signer, keysigner.KeyRef{Label: keyLabel}, initResp.Nonce, time.Now())
|
||||
if signErr != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to sign registration attestation: %v", signErr).WithCause(signErr)
|
||||
}
|
||||
beginOpts = larkauth.AppRegistrationBeginOptions{
|
||||
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
||||
AuthAttestation: attestation,
|
||||
}
|
||||
}
|
||||
|
||||
// Restore flow: re-register the existing app instead of creating a new one.
|
||||
beginOpts.RestoreAppID = restoreAppID
|
||||
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, beginOpts, f.IOStreams.ErrOut)
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||
}
|
||||
@@ -311,28 +213,18 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// The final auth method is decided by the user/admin at confirmation and
|
||||
// returned by poll — NOT necessarily what we requested. Selecting an existing
|
||||
// client_secret app, for example, yields client_secret even though we sent
|
||||
// private_key_jwt. Trust the result so we persist the truth.
|
||||
finalMethod := resolveFinalAuthMethod(result.AuthMethods, authMethod)
|
||||
|
||||
// Lark brand special case (client_secret only): a lark-tenant app returns its
|
||||
// secret only from the lark endpoint. private_key_jwt returns no secret, so
|
||||
// this retry does not apply.
|
||||
if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
// Step 4: Handle Lark brand special case
|
||||
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
|
||||
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
||||
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
|
||||
}
|
||||
finalMethod = resolveFinalAuthMethod(result.AuthMethods, authMethod)
|
||||
}
|
||||
|
||||
if result.ClientID == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id")
|
||||
}
|
||||
if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_secret")
|
||||
if result.ClientID == "" || result.ClientSecret == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
||||
}
|
||||
|
||||
// Determine final brand from response
|
||||
@@ -343,67 +235,13 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
finalBrand = core.BrandFeishu
|
||||
}
|
||||
|
||||
// Surface a downgrade: requested private_key_jwt but the app resolved to a
|
||||
// secret-based method (e.g. an existing app was selected). The key was NOT
|
||||
// bound, so we must store the secret method, not private_key_jwt.
|
||||
if authMethod == core.AuthMethodPrivateKeyJWT && finalMethod != core.AuthMethodPrivateKeyJWT {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] note: requested private_key_jwt, but the app uses %q (e.g. an existing app was selected); storing %q.\n", finalMethod, finalMethod)
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
|
||||
|
||||
keyToStore := ""
|
||||
if finalMethod == core.AuthMethodPrivateKeyJWT {
|
||||
keyToStore = keyLabel
|
||||
}
|
||||
if err := validatePKJWTKeyBinding(finalMethod, keyToStore); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &configInitResult{
|
||||
Mode: "create",
|
||||
Brand: finalBrand,
|
||||
AppID: result.ClientID,
|
||||
AppSecret: result.ClientSecret, // empty for private_key_jwt; real secret otherwise
|
||||
AuthMethod: finalMethod,
|
||||
KeyLabel: keyToStore,
|
||||
Mode: "create",
|
||||
Brand: finalBrand,
|
||||
AppID: result.ClientID,
|
||||
AppSecret: result.ClientSecret,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validatePKJWTKeyBinding rejects a registration that resolved to
|
||||
// private_key_jwt without a signing key bound to it. keyLabel is non-empty only
|
||||
// when the local flow chose private_key_jwt and signed a TEE attestation; a
|
||||
// resolved method of private_key_jwt with no key handle would save an unusable
|
||||
// config (rejected later at config load, surfacing as "saved OK, fails on first
|
||||
// use"), so it is caught here at registration time instead.
|
||||
func validatePKJWTKeyBinding(finalMethod, keyLabel string) error {
|
||||
if finalMethod == core.AuthMethodPrivateKeyJWT && keyLabel == "" {
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient,
|
||||
"registration resolved to private_key_jwt but no signing key was bound to this app (an existing secret-based app may have been selected)").
|
||||
WithHint("re-register with: lark-cli config init --new --auth-method private_key_jwt")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveFinalAuthMethod picks the authoritative method from the poll result,
|
||||
// preferring private_key_jwt, then client_secret. It falls back to the requested
|
||||
// method when the server returns nothing (older servers).
|
||||
func resolveFinalAuthMethod(serverMethods []string, requested string) string {
|
||||
if len(serverMethods) == 0 {
|
||||
if requested == "" {
|
||||
return core.AuthMethodClientSecret
|
||||
}
|
||||
return requested
|
||||
}
|
||||
for _, m := range serverMethods {
|
||||
if m == core.AuthMethodPrivateKeyJWT {
|
||||
return core.AuthMethodPrivateKeyJWT
|
||||
}
|
||||
}
|
||||
for _, m := range serverMethods {
|
||||
if m == core.AuthMethodClientSecret {
|
||||
return core.AuthMethodClientSecret
|
||||
}
|
||||
}
|
||||
return serverMethods[0]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// probeTimeout is the total wall-clock budget for the credential probe step
|
||||
@@ -91,32 +90,3 @@ func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runProbePKJWT does a best-effort key-binding validation after a private_key_jwt
|
||||
// config is saved: it signs a client_assertion with the local platform key and
|
||||
// mints a token. A typed error (a deterministic server rejection — e.g. the key
|
||||
// is not bound to this app) is propagated so `config init` exits non-zero with
|
||||
// the canonical envelope; untyped errors (transport / HTTP / parse / timeout)
|
||||
// are swallowed (return nil). The mint itself is the probe — no second call.
|
||||
func runProbePKJWT(parent context.Context, factory *cmdutil.Factory, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) error {
|
||||
if factory == nil || signer == nil {
|
||||
return nil
|
||||
}
|
||||
httpClient, err := factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
if _, err := credential.FetchTATWithAssertion(ctx, httpClient, brand, clientID, signer, keyLabel); err != nil {
|
||||
// Typed = deterministic credential rejection → propagate. Untyped
|
||||
// (transport / HTTP / parse / timeout) is ambiguous → stay silent.
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@ package config
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
crand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -22,17 +17,14 @@ import (
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// fakeRT routes requests to per-path handlers and records what it saw.
|
||||
type fakeRT struct {
|
||||
tatHandler func(req *http.Request) (*http.Response, error)
|
||||
probeHandler func(req *http.Request) (*http.Response, error)
|
||||
oauthHandler func(req *http.Request) (*http.Response, error)
|
||||
tatCalls int
|
||||
probeCalls int
|
||||
oauthCalls int
|
||||
probeReq *http.Request
|
||||
probeBody string
|
||||
}
|
||||
@@ -56,50 +48,10 @@ func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
|
||||
}
|
||||
return f.probeHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/authen/v2/oauth/token"):
|
||||
f.oauthCalls++
|
||||
if f.oauthHandler == nil {
|
||||
return jsonResp(200, `{"access_token":"t-jwt"}`), nil
|
||||
}
|
||||
return f.oauthHandler(req)
|
||||
}
|
||||
return nil, errors.New("unexpected URL: " + req.URL.String())
|
||||
}
|
||||
|
||||
// probeTestSigner is an in-memory real ECDSA P-256 signer used to sign the
|
||||
// client_assertion in runProbePKJWT tests (authMethodTestSigner returns a nil
|
||||
// key and cannot sign).
|
||||
type probeTestSigner struct{ key *ecdsa.PrivateKey }
|
||||
|
||||
func newProbeTestSigner(t *testing.T) *probeTestSigner {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &probeTestSigner{key: k}
|
||||
}
|
||||
|
||||
func (p *probeTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return p.key.Public(), nil
|
||||
}
|
||||
|
||||
func (p *probeTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return p.key.Public(), nil
|
||||
}
|
||||
|
||||
func (p *probeTestSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
|
||||
h := sha256.Sum256(in)
|
||||
r, s, err := ecdsa.Sign(crand.Reader, p.key, h[:])
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
s.FillBytes(sig[32:])
|
||||
return sig, keysigner.AlgES256, nil
|
||||
}
|
||||
|
||||
func jsonResp(code int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: code,
|
||||
@@ -333,42 +285,3 @@ func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||
// must stay silent and not block.
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
// runProbePKJWT: a deterministic server rejection (invalid_client) is propagated
|
||||
// as a typed ConfigError so config init exits non-zero.
|
||||
func TestRunProbePKJWT_DeterministicReject_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) {
|
||||
return jsonResp(401, `{"error":"invalid_client","error_description":"unknown key"}`), nil
|
||||
}}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key")
|
||||
if err == nil || !errs.IsTyped(err) {
|
||||
t.Fatalf("expected propagated typed error, got %T %v", err, err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbePKJWT must not write stderr, got %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// runProbePKJWT: ambiguous upstream noise (HTTP 503) is swallowed — silent, exit 0.
|
||||
func TestRunProbePKJWT_Ambiguous_Silent(t *testing.T) {
|
||||
rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) {
|
||||
return jsonResp(503, `unavailable`), nil
|
||||
}}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key"), errBuf)
|
||||
}
|
||||
|
||||
// runProbePKJWT: a successful mint returns nil.
|
||||
func TestRunProbePKJWT_Success_Silent(t *testing.T) {
|
||||
rt := &fakeRT{} // default oauth handler returns 200 + access_token
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key"), errBuf)
|
||||
}
|
||||
|
||||
// runProbePKJWT: a nil signer is a defensive no-op (should not be reached, must
|
||||
// not panic).
|
||||
func TestRunProbePKJWT_NilSigner_Silent(t *testing.T) {
|
||||
f, errBuf := fakeFactory(t, &fakeRT{})
|
||||
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", nil, "k"), errBuf)
|
||||
}
|
||||
|
||||
@@ -10,25 +10,9 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestRunRestoreFlow_NothingToRestore covers the early guards that return before
|
||||
// any network/registration call: no config at all, and a config whose resolved
|
||||
// app has no app id (nothing to send on begin).
|
||||
func TestRunRestoreFlow_NothingToRestore(t *testing.T) {
|
||||
// No config on disk.
|
||||
if err := runRestoreFlow(&ConfigInitOptions{}, nil, nil, nil); err == nil {
|
||||
t.Fatal("expected error when there is no config to restore")
|
||||
}
|
||||
// Config present but the resolved app has no app id.
|
||||
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: ""}}}
|
||||
if err := runRestoreFlow(&ConfigInitOptions{}, existing, nil, nil); err == nil {
|
||||
t.Fatal("expected error when the resolved app has no app id")
|
||||
}
|
||||
}
|
||||
|
||||
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
|
||||
// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2:
|
||||
// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials,
|
||||
@@ -135,62 +119,3 @@ func assertValidationParam(t *testing.T, err error, wantParam string) {
|
||||
t.Errorf("Param = %q, want %q", valErr.Param, wantParam)
|
||||
}
|
||||
}
|
||||
|
||||
// countingKeychain is an in-memory KeychainAccess that records whether Remove
|
||||
// was invoked, so the stale-secret cleanup can be asserted without a real OS
|
||||
// keychain.
|
||||
type countingKeychain struct {
|
||||
store map[string]string
|
||||
removeCalled bool
|
||||
}
|
||||
|
||||
func newCountingKeychain() *countingKeychain {
|
||||
return &countingKeychain{store: map[string]string{}}
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Get(service, account string) (string, error) {
|
||||
v, ok := k.store[service+"/"+account]
|
||||
if !ok {
|
||||
return "", keychain.ErrNotFound
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Set(service, account, value string) error {
|
||||
k.store[service+"/"+account] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *countingKeychain) Remove(service, account string) error {
|
||||
k.removeCalled = true
|
||||
delete(k.store, service+"/"+account)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRemoveStaleSecretForPKJWT_SameAppID(t *testing.T) {
|
||||
kc := newCountingKeychain()
|
||||
ref, err := core.ForStorage("cli_same", core.PlainSecret("old-secret"), kc) // → Source:"keychain"
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_same", AppSecret: ref}}}
|
||||
removeStaleSecretForPKJWT(existing, "", "cli_same", kc)
|
||||
if !kc.removeCalled {
|
||||
t.Error("same appId with keychain secret: expected kc.Remove to be invoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveStaleSecretForPKJWT_DifferentAppID(t *testing.T) {
|
||||
kc := newCountingKeychain()
|
||||
ref, _ := core.ForStorage("cli_old", core.PlainSecret("old-secret"), kc)
|
||||
kc.removeCalled = false // ForStorage does not call Remove, but reset to be safe
|
||||
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_old", AppSecret: ref}}}
|
||||
removeStaleSecretForPKJWT(existing, "", "cli_new", kc)
|
||||
if kc.removeCalled {
|
||||
t.Error("different appId: must NOT remove")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveStaleSecretForPKJWT_NilExisting(t *testing.T) {
|
||||
removeStaleSecretForPKJWT(nil, "", "cli_x", newCountingKeychain()) // must not panic
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -20,7 +19,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
@@ -134,9 +132,6 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
}
|
||||
|
||||
// ── 3b. private_key_jwt / TEE signer (local; runs even with --offline) ──
|
||||
checks = append(checks, teeSignerCheck(opts.Ctx, cfg))
|
||||
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
|
||||
@@ -150,54 +145,6 @@ func identityCheck(name string, id identitydiag.Identity) checkResult {
|
||||
return warn(name, id.Message, id.Hint)
|
||||
}
|
||||
|
||||
const teeUnavailableHint = "ensure the device secure hardware is accessible (Linux TPM: add your user to the 'tss' group or run with sufficient privileges)"
|
||||
|
||||
// teeSignerCheck reports the private_key_jwt signing backend (TEE/TPM) status.
|
||||
// The probe is local hardware only (no network), so it runs even with --offline;
|
||||
// in a build without a TEE signer it short-circuits without touching any
|
||||
// hardware. It is a hard requirement for private_key_jwt apps and purely
|
||||
// informational for client_secret apps.
|
||||
func teeSignerCheck(ctx context.Context, cfg *core.CliConfig) checkResult {
|
||||
usesPKJWT := cfg != nil && cfg.AuthMethod == core.AuthMethodPrivateKeyJWT
|
||||
info, ok, err := keysigner.ProbeActiveHardware(ctx)
|
||||
return teeCheckResult(info, ok, err, usesPKJWT)
|
||||
}
|
||||
|
||||
// teeCheckResult maps a hardware probe to a doctor check. Split out from
|
||||
// teeSignerCheck so the full matrix is unit-testable without a TPM.
|
||||
func teeCheckResult(info keysigner.HardwareInfo, ok bool, probeErr error, usesPKJWT bool) checkResult {
|
||||
const name = "tee_signer"
|
||||
|
||||
// No signer registered → private_key_jwt is unsupported on this build.
|
||||
if !ok {
|
||||
if usesPKJWT {
|
||||
return fail(name,
|
||||
"app uses private_key_jwt but this build has no TEE key signer",
|
||||
"the platform key signer ships by default on macOS, Linux, and Windows/amd64; this platform (e.g. Windows/arm64) has none — use a supported platform or re-register with --auth-method client_secret")
|
||||
}
|
||||
return skip(name, "no TEE signer in this build (only private_key_jwt is affected; client_secret is unaffected)")
|
||||
}
|
||||
|
||||
backend := info.Backend
|
||||
if backend == "" {
|
||||
backend = "tee"
|
||||
}
|
||||
|
||||
switch {
|
||||
case probeErr != nil:
|
||||
return warn(name, fmt.Sprintf("%s signer present but probe errored: %s", backend, probeErr), "")
|
||||
case info.Available:
|
||||
if info.VendorName != "" {
|
||||
return pass(name, fmt.Sprintf("%s TEE available (%s)", backend, info.VendorName))
|
||||
}
|
||||
return pass(name, fmt.Sprintf("%s TEE available", backend))
|
||||
case usesPKJWT:
|
||||
return fail(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint)
|
||||
default:
|
||||
return warn(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint)
|
||||
}
|
||||
}
|
||||
|
||||
// networkChecks probes Open API and MCP endpoints concurrently.
|
||||
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
|
||||
if opts.Offline {
|
||||
@@ -287,90 +234,14 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
|
||||
}
|
||||
}
|
||||
|
||||
workspace := core.CurrentWorkspace().Display()
|
||||
// A terminal on STDOUT gets a readable report; pipes, redirects, scripts and
|
||||
// tests keep the stable JSON contract (NO_COLOR disables ANSI styling).
|
||||
// StdoutIsTerminal checks stdout specifically — IOStreams.IsTerminal reflects
|
||||
// stdin, which would wrongly send the human report into `doctor | jq`.
|
||||
if f.IOStreams.StdoutIsTerminal() {
|
||||
renderDoctorHuman(f.IOStreams.Out, workspace, checks, allOK, os.Getenv("NO_COLOR") == "")
|
||||
} else {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": allOK,
|
||||
"workspace": workspace,
|
||||
"checks": checks,
|
||||
})
|
||||
result := map[string]interface{}{
|
||||
"ok": allOK,
|
||||
"workspace": core.CurrentWorkspace().Display(),
|
||||
"checks": checks,
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
if !allOK {
|
||||
return output.ErrBare(1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderDoctorHuman writes a readable health report: one aligned line per check
|
||||
// with a colored status tag, an indented hint when present, and a summary line.
|
||||
func renderDoctorHuman(w io.Writer, workspace string, checks []checkResult, allOK, color bool) {
|
||||
const (
|
||||
green = "\033[32m"
|
||||
yellow = "\033[33m"
|
||||
red = "\033[31m"
|
||||
gray = "\033[90m"
|
||||
bold = "\033[1m"
|
||||
reset = "\033[0m"
|
||||
)
|
||||
colorOf := map[string]string{"pass": green, "warn": yellow, "fail": red, "skip": gray}
|
||||
tagOf := map[string]string{"pass": "PASS", "warn": "WARN", "fail": "FAIL", "skip": "SKIP"}
|
||||
paint := func(code, s string) string {
|
||||
if !color || code == "" {
|
||||
return s
|
||||
}
|
||||
return code + s + reset
|
||||
}
|
||||
|
||||
nameW := 0
|
||||
for _, c := range checks {
|
||||
if len(c.Name) > nameW {
|
||||
nameW = len(c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n%s (workspace: %s)\n\n", paint(bold, "lark-cli doctor"), workspace)
|
||||
|
||||
var passN, warnN, failN, skipN int
|
||||
for _, c := range checks {
|
||||
tag := tagOf[c.Status]
|
||||
if tag == "" {
|
||||
tag = "????"
|
||||
}
|
||||
fmt.Fprintf(w, " %s %-*s %s\n", paint(colorOf[c.Status], "["+tag+"]"), nameW, c.Name, c.Message)
|
||||
if c.Hint != "" {
|
||||
fmt.Fprintf(w, " %-*s %s\n", nameW, "", paint(gray, "↳ "+c.Hint))
|
||||
}
|
||||
switch c.Status {
|
||||
case "pass":
|
||||
passN++
|
||||
case "warn":
|
||||
warnN++
|
||||
case "fail":
|
||||
failN++
|
||||
case "skip":
|
||||
skipN++
|
||||
}
|
||||
}
|
||||
|
||||
headline := paint(green, "healthy")
|
||||
if !allOK {
|
||||
headline = paint(red, "problems found")
|
||||
}
|
||||
fmt.Fprintf(w, "\n %s — %d passed", headline, passN)
|
||||
if warnN > 0 {
|
||||
fmt.Fprintf(w, ", %d warning(s)", warnN)
|
||||
}
|
||||
if failN > 0 {
|
||||
fmt.Fprintf(w, ", %d failed", failN)
|
||||
}
|
||||
if skipN > 0 {
|
||||
fmt.Fprintf(w, ", %d skipped", skipN)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
@@ -4,18 +4,14 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
||||
@@ -143,107 +139,6 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
assertCheck(t, got.Checks, "identity_ready", "pass")
|
||||
}
|
||||
|
||||
func TestTeeCheckResult(t *testing.T) {
|
||||
avail := keysigner.HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"}
|
||||
unavail := keysigner.HardwareInfo{Backend: "tpm2", Reason: "open /dev/tpmrm0: permission denied"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
info keysigner.HardwareInfo
|
||||
ok bool
|
||||
probeErr error
|
||||
pkjwt bool
|
||||
want string
|
||||
}{
|
||||
{"no signer + private_key_jwt → fail", keysigner.HardwareInfo{}, false, nil, true, "fail"},
|
||||
{"no signer + client_secret → skip", keysigner.HardwareInfo{}, false, nil, false, "skip"},
|
||||
{"available + private_key_jwt → pass", avail, true, nil, true, "pass"},
|
||||
{"available + client_secret → pass", avail, true, nil, false, "pass"},
|
||||
{"unavailable + private_key_jwt → fail", unavail, true, nil, true, "fail"},
|
||||
{"unavailable + client_secret → warn", unavail, true, nil, false, "warn"},
|
||||
{"probe error → warn", keysigner.HardwareInfo{Backend: "tpm2"}, true, errors.New("boom"), true, "warn"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := teeCheckResult(tc.info, tc.ok, tc.probeErr, tc.pkjwt)
|
||||
if got.Name != "tee_signer" {
|
||||
t.Errorf("name = %q, want tee_signer", got.Name)
|
||||
}
|
||||
if got.Status != tc.want {
|
||||
t.Errorf("status = %q, want %q (msg=%q)", got.Status, tc.want, got.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoctorRun_TeeSignerWired proves the tee_signer check is part of doctorRun.
|
||||
// It asserts the build-independent invariant (a client_secret app must never
|
||||
// FAIL on TEE) so the test passes whether or not a signer is compiled in.
|
||||
func TestDoctorRun_TeeSignerWired(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default", AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
var got struct {
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
var c *checkResult
|
||||
for i := range got.Checks {
|
||||
if got.Checks[i].Name == "tee_signer" {
|
||||
c = &got.Checks[i]
|
||||
}
|
||||
}
|
||||
if c == nil {
|
||||
t.Fatalf("tee_signer check not present in doctor output: %#v", got.Checks)
|
||||
}
|
||||
if c.Status == "fail" {
|
||||
t.Errorf("tee_signer = fail for a client_secret app; want skip/warn/pass (msg=%q)", c.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDoctorHuman(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
checks := []checkResult{
|
||||
pass("cli_version", "1.0.50"),
|
||||
warn("tee_signer", "tpm2 signer present but TEE unavailable", "add your user to the 'tss' group"),
|
||||
fail("identity_ready", "no usable identity", "run: lark-cli auth status --verify"),
|
||||
skip("endpoint_open", "skipped (--offline)"),
|
||||
}
|
||||
renderDoctorHuman(&buf, "local", checks, false, false)
|
||||
out := buf.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"lark-cli doctor", "workspace: local",
|
||||
"[PASS]", "cli_version", "1.0.50",
|
||||
"[WARN]", "tee_signer", "↳ add your user to the 'tss' group",
|
||||
"[FAIL]", "identity_ready", "↳ run: lark-cli auth status --verify",
|
||||
"[SKIP]", "endpoint_open",
|
||||
"problems found", "1 passed", "1 warning(s)", "1 failed", "1 skipped",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\n---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "\033[") {
|
||||
t.Errorf("color=false but ANSI escapes present:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
|
||||
18
go.mod
18
go.mod
@@ -7,8 +7,6 @@ require (
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c
|
||||
github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4
|
||||
github.com/gofrs/flock v0.8.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
@@ -29,10 +27,7 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require github.com/ebitengine/purego v0.10.1
|
||||
|
||||
require (
|
||||
github.com/StackExchange/wmi v1.2.1 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
@@ -47,23 +42,12 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/go-ole/go-ole v1.2.5 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/btree v1.0.1 // indirect
|
||||
github.com/google/certificate-transparency-go v1.1.2 // indirect
|
||||
github.com/google/certtostore v1.0.3-0.20230404221207-8d01647071cc // indirect
|
||||
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae // indirect
|
||||
github.com/google/go-attestation v0.5.1 // indirect
|
||||
github.com/google/go-tpm v0.9.0 // indirect
|
||||
github.com/google/go-tspi v0.3.0 // indirect
|
||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -73,12 +57,10 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/smarty/assertions v1.15.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
)
|
||||
|
||||
@@ -31,11 +31,6 @@ type AppRegistrationResult struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
UserInfo *AppRegUserInfo
|
||||
// AuthMethods is the authoritative auth method(s) the app must use, as
|
||||
// decided by the user/admin at confirmation (20260409 `auth_method` field).
|
||||
// It may differ from what the client requested — e.g. selecting an existing
|
||||
// client_secret app. Empty on older servers.
|
||||
AuthMethods []string
|
||||
}
|
||||
|
||||
// AppRegUserInfo contains user info returned from app registration.
|
||||
@@ -44,81 +39,8 @@ type AppRegUserInfo struct {
|
||||
TenantBrand string // "feishu" or "lark"
|
||||
}
|
||||
|
||||
// AppRegistrationInit is the response from the app registration init endpoint.
|
||||
type AppRegistrationInit struct {
|
||||
Nonce string
|
||||
SupportedAuthMethods []string // e.g. ["client_secret", "private_key_jwt"]
|
||||
}
|
||||
|
||||
// AppRegistrationBeginOptions parametrizes the registration begin request.
|
||||
// A zero value selects the legacy client_secret flow, preserving prior behavior.
|
||||
type AppRegistrationBeginOptions struct {
|
||||
AuthMethod string // "" => client_secret; core.AuthMethodPrivateKeyJWT
|
||||
AuthAttestation string // private_key_jwt: the TEE-signed attestation JWT
|
||||
RestoreAppID string // when set, asks the server to re-register this existing app
|
||||
}
|
||||
|
||||
// RequestAppRegistrationInit performs the init step of the registration flow,
|
||||
// returning a server nonce (to be embedded in a TEE-signed attestation JWT) and
|
||||
// the auth methods the server supports for this archetype.
|
||||
func RequestAppRegistrationInit(httpClient *http.Client) (*AppRegistrationInit, error) {
|
||||
// Registration always begins against the feishu accounts host (mirrors begin).
|
||||
endpoint := core.ResolveEndpoints(core.BrandFeishu).Accounts + PathAppRegistration
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "init")
|
||||
form.Set("archetype", "PersonalAgent")
|
||||
|
||||
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("app registration init failed: read body: %w", err)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, fmt.Errorf("app registration init failed: HTTP %d – response not JSON", resp.StatusCode)
|
||||
}
|
||||
|
||||
if _, hasError := data["error"]; resp.StatusCode >= 400 || hasError {
|
||||
msg := getStr(data, "error_description")
|
||||
if msg == "" {
|
||||
msg = getStr(data, "error")
|
||||
}
|
||||
if msg == "" {
|
||||
msg = "Unknown error"
|
||||
}
|
||||
return nil, fmt.Errorf("app registration init failed: %s", msg)
|
||||
}
|
||||
|
||||
out := &AppRegistrationInit{Nonce: getStr(data, "nonce")}
|
||||
if methods, ok := data["supported_auth_methods"].([]interface{}); ok {
|
||||
for _, m := range methods {
|
||||
if s, ok := m.(string); ok {
|
||||
out.SupportedAuthMethods = append(out.SupportedAuthMethods, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.Nonce == "" {
|
||||
return nil, fmt.Errorf("app registration init failed: server returned no nonce")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RequestAppRegistration initiates the app registration device flow (begin step).
|
||||
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts AppRegistrationBeginOptions, errOut io.Writer) (*AppRegistrationResponse, error) {
|
||||
// RequestAppRegistration initiates the app registration device flow.
|
||||
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
@@ -127,24 +49,11 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
|
||||
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
|
||||
endpoint := regEp.Accounts + PathAppRegistration
|
||||
|
||||
authMethod := opts.AuthMethod
|
||||
if authMethod == "" {
|
||||
authMethod = core.AuthMethodClientSecret
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("action", "begin")
|
||||
form.Set("archetype", "PersonalAgent")
|
||||
form.Set("auth_method", authMethod)
|
||||
form.Set("auth_method", "client_secret")
|
||||
form.Set("request_user_info", "open_id tenant_brand")
|
||||
if opts.AuthAttestation != "" {
|
||||
form.Set("auth_attestation", opts.AuthAttestation)
|
||||
}
|
||||
// Restore flow: carry the existing app id so the server re-registers it
|
||||
// rather than creating a new app.
|
||||
if opts.RestoreAppID != "" {
|
||||
form.Set("app_id", opts.RestoreAppID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
@@ -186,24 +95,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
|
||||
|
||||
userCode := getStr(data, "user_code")
|
||||
verificationUri := getStr(data, "verification_uri")
|
||||
// Prefer the server-provided complete URL (currently /page/launcher); fall
|
||||
// back to building it from verification_uri, then to /page/launcher. The old
|
||||
// hard-coded /page/cli is stale — the server now returns /page/launcher.
|
||||
verificationUriComplete := getStr(data, "verification_uri_complete")
|
||||
if verificationUriComplete == "" {
|
||||
base := verificationUri
|
||||
if base == "" {
|
||||
base = ep.Open + "/page/launcher"
|
||||
}
|
||||
// The server may return verification_uri with its own query (e.g.
|
||||
// client_id when registering against an existing app), so join with
|
||||
// the same ?/& logic as BuildVerificationURL.
|
||||
sep := "?"
|
||||
if strings.Contains(base, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
verificationUriComplete = base + sep + "user_code=" + url.QueryEscape(userCode)
|
||||
}
|
||||
verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode)
|
||||
|
||||
return &AppRegistrationResponse{
|
||||
DeviceCode: getStr(data, "device_code"),
|
||||
@@ -215,26 +107,6 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseAuthMethods normalizes the poll response `auth_method` field, which the
|
||||
// server returns as a JSON array of strings (e.g. ["private_key_jwt"]) — or, on
|
||||
// some variants, a single space-separated string.
|
||||
func parseAuthMethods(v interface{}) []string {
|
||||
switch t := v.(type) {
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, m := range t {
|
||||
if s, ok := m.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
return strings.Fields(t)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// BuildVerificationURL appends CLI tracking parameters to the verification URL.
|
||||
func BuildVerificationURL(baseURL, cliVersion string) string {
|
||||
sep := "&"
|
||||
@@ -315,7 +187,6 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
|
||||
result := &AppRegistrationResult{
|
||||
ClientID: getStr(data, "client_id"),
|
||||
ClientSecret: getStr(data, "client_secret"),
|
||||
AuthMethods: parseAuthMethods(data["auth_method"]),
|
||||
}
|
||||
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
|
||||
result.UserInfo = &AppRegUserInfo{
|
||||
|
||||
@@ -4,14 +4,8 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
@@ -37,184 +31,3 @@ func Test_BuildVerificationURL(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// captureClient returns an http.Client that records the last request's form body
|
||||
// and replies with the given JSON payload.
|
||||
func captureClient(gotBody *url.Values, respJSON string) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
v, _ := url.ParseQuery(string(b))
|
||||
*gotBody = v
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(respJSON)),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAppRegistrationInit_ParsesNonceAndMethods(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, `{"nonce":"n-123","supported_auth_methods":["client_secret","private_key_jwt"]}`)
|
||||
|
||||
out, err := RequestAppRegistrationInit(hc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Nonce != "n-123" {
|
||||
t.Errorf("nonce = %q, want n-123", out.Nonce)
|
||||
}
|
||||
if len(out.SupportedAuthMethods) != 2 || out.SupportedAuthMethods[1] != "private_key_jwt" {
|
||||
t.Errorf("methods = %v", out.SupportedAuthMethods)
|
||||
}
|
||||
if body.Get("action") != "init" {
|
||||
t.Errorf("action = %q, want init", body.Get("action"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAppRegistrationInit_ErrorOnMissingNonce(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, `{"supported_auth_methods":["client_secret"]}`)
|
||||
if _, err := RequestAppRegistrationInit(hc); err == nil {
|
||||
t.Fatal("expected error when server returns no nonce")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestAppRegistrationInit_EmptySupportedAuthMethods covers the older-server
|
||||
// back-compat path: an empty supported_auth_methods array parses to an empty
|
||||
// slice, so the init guard in cmd/config/init_interactive.go
|
||||
// (`len(SupportedAuthMethods) > 0 && !slices.Contains(...)`) stays false and does
|
||||
// NOT reject the requested private_key_jwt. This aligns with
|
||||
// resolveFinalAuthMethod(nil/[], private_key_jwt) == private_key_jwt
|
||||
// (see cmd/config TestResolveFinalAuthMethod).
|
||||
func TestRequestAppRegistrationInit_EmptySupportedAuthMethods(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, `{"nonce":"n-1","supported_auth_methods":[]}`)
|
||||
|
||||
out, err := RequestAppRegistrationInit(hc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Nonce != "n-1" {
|
||||
t.Errorf("nonce = %q, want n-1", out.Nonce)
|
||||
}
|
||||
if len(out.SupportedAuthMethods) != 0 {
|
||||
t.Errorf("SupportedAuthMethods = %v, want empty", out.SupportedAuthMethods)
|
||||
}
|
||||
// Reproduce the init guard expression on the real parsed result: an empty
|
||||
// slice must NOT reject private_key_jwt.
|
||||
rejected := len(out.SupportedAuthMethods) > 0 &&
|
||||
!slices.Contains(out.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT)
|
||||
if rejected {
|
||||
t.Error("empty SupportedAuthMethods must allow private_key_jwt (older-server back-compat)")
|
||||
}
|
||||
}
|
||||
|
||||
const beginRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`
|
||||
|
||||
func TestRequestAppRegistration_BeginDefaultsToClientSecret(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, beginRespJSON)
|
||||
|
||||
if _, err := RequestAppRegistration(hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Get("action") != "begin" {
|
||||
t.Errorf("action = %q", body.Get("action"))
|
||||
}
|
||||
if body.Get("auth_method") != "client_secret" {
|
||||
t.Errorf("auth_method = %q, want client_secret (default)", body.Get("auth_method"))
|
||||
}
|
||||
if body.Has("auth_attestation") {
|
||||
t.Errorf("auth_attestation should be absent for client_secret, got %q", body.Get("auth_attestation"))
|
||||
}
|
||||
// Normal (non-restore) begin must NOT carry app_id.
|
||||
if body.Has("app_id") {
|
||||
t.Errorf("app_id should be absent when RestoreAppID is empty, got %q", body.Get("app_id"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestAppRegistration_BeginRestoreAppID verifies the restore flow sends the
|
||||
// existing app id on begin so the server re-registers that app.
|
||||
func TestRequestAppRegistration_BeginRestoreAppID(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, beginRespJSON)
|
||||
|
||||
opts := AppRegistrationBeginOptions{RestoreAppID: "cli_restore_me"}
|
||||
if _, err := RequestAppRegistration(hc, core.BrandFeishu, opts, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Get("action") != "begin" {
|
||||
t.Errorf("action = %q, want begin", body.Get("action"))
|
||||
}
|
||||
if body.Get("app_id") != "cli_restore_me" {
|
||||
t.Errorf("app_id = %q, want cli_restore_me", body.Get("app_id"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAppRegistration_VerificationURICompleteFallback(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
resp string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "bare verification_uri",
|
||||
resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`,
|
||||
want: "https://example/verify?user_code=uc",
|
||||
},
|
||||
{
|
||||
name: "verification_uri with existing query",
|
||||
resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify?client_id=cli_x","expires_in":300,"interval":5}`,
|
||||
want: "https://example/verify?client_id=cli_x&user_code=uc",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, tc.resp)
|
||||
got, err := RequestAppRegistration(hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.VerificationUriComplete != tc.want {
|
||||
t.Errorf("VerificationUriComplete = %q, want %q", got.VerificationUriComplete, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAuthMethods(t *testing.T) {
|
||||
if got := parseAuthMethods([]interface{}{"private_key_jwt", "client_secret"}); len(got) != 2 || got[0] != "private_key_jwt" {
|
||||
t.Errorf("array form = %v", got)
|
||||
}
|
||||
if got := parseAuthMethods("client_secret private_key_jwt"); len(got) != 2 || got[1] != "private_key_jwt" {
|
||||
t.Errorf("string form = %v", got)
|
||||
}
|
||||
if got := parseAuthMethods(nil); got != nil {
|
||||
t.Errorf("nil form = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestAppRegistration_BeginPrivateKeyJWT(t *testing.T) {
|
||||
var body url.Values
|
||||
hc := captureClient(&body, beginRespJSON)
|
||||
|
||||
opts := AppRegistrationBeginOptions{
|
||||
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
||||
AuthAttestation: "header.claims.sig",
|
||||
}
|
||||
if _, err := RequestAppRegistration(hc, core.BrandFeishu, opts, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Get("auth_method") != "private_key_jwt" {
|
||||
t.Errorf("auth_method = %q, want private_key_jwt", body.Get("auth_method"))
|
||||
}
|
||||
if body.Get("auth_attestation") != "header.claims.sig" {
|
||||
t.Errorf("auth_attestation = %q", body.Get("auth_attestation"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth/jwt"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// ClientAuth describes how to authenticate the OAuth client at the token
|
||||
// endpoint: with a client_secret (default) or a TEE-signed client_assertion
|
||||
// (private_key_jwt).
|
||||
type ClientAuth struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
|
||||
Signer keysigner.Signer
|
||||
KeyLabel string
|
||||
}
|
||||
|
||||
// ClientAuthFromConfig builds a ClientAuth from resolved config, picking up the
|
||||
// active key signer for private_key_jwt apps.
|
||||
func ClientAuthFromConfig(cfg *core.CliConfig) ClientAuth {
|
||||
if cfg == nil {
|
||||
return ClientAuth{}
|
||||
}
|
||||
return ClientAuth{
|
||||
AppID: cfg.AppID,
|
||||
AppSecret: cfg.AppSecret,
|
||||
AuthMethod: cfg.AuthMethod,
|
||||
KeyLabel: cfg.KeyLabel,
|
||||
Signer: keysigner.Active(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c ClientAuth) isPrivateKeyJWT() bool { return c.AuthMethod == core.AuthMethodPrivateKeyJWT }
|
||||
|
||||
// applyClientAssertion adds client_assertion(+type) to a token-endpoint form for
|
||||
// private_key_jwt and returns true. For client_secret it returns false, leaving
|
||||
// the caller to apply its own secret-based authentication. audience is the token
|
||||
// endpoint URL (the assertion's aud claim).
|
||||
func (c ClientAuth) applyClientAssertion(ctx context.Context, form url.Values, audience string) (bool, error) {
|
||||
if !c.isPrivateKeyJWT() {
|
||||
return false, nil
|
||||
}
|
||||
if c.Signer == nil {
|
||||
return false, fmt.Errorf("private_key_jwt requires a key signer, but none is available on this build")
|
||||
}
|
||||
assertion, err := jwt.SignClientAssertion(ctx, c.Signer, keysigner.KeyRef{Label: c.KeyLabel}, c.AppID, audience, time.Now())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
form.Set("client_assertion_type", jwt.ClientAssertionType)
|
||||
form.Set("client_assertion", assertion)
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth/jwt"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// fakeAuthSigner is a real in-memory ECDSA P-256 signer for client-auth tests.
|
||||
type fakeAuthSigner struct{ key *ecdsa.PrivateKey }
|
||||
|
||||
func newFakeAuthSigner(t *testing.T) *fakeAuthSigner {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fakeAuthSigner{key: k}
|
||||
}
|
||||
|
||||
func (f *fakeAuthSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return f.key.Public(), nil
|
||||
}
|
||||
func (f *fakeAuthSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return f.key.Public(), nil
|
||||
}
|
||||
func (f *fakeAuthSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
|
||||
h := sha256.Sum256(in)
|
||||
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
s.FillBytes(sig[32:])
|
||||
return sig, keysigner.AlgES256, nil
|
||||
}
|
||||
|
||||
func TestClientAuth_applyClientAssertion_ClientSecret(t *testing.T) {
|
||||
ca := ClientAuth{AppID: "cli_a", AppSecret: "sec"} // AuthMethod "" => client_secret
|
||||
form := url.Values{}
|
||||
used, err := ca.applyClientAssertion(context.Background(), form, "https://aud/token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if used {
|
||||
t.Error("client_secret must not produce a client_assertion")
|
||||
}
|
||||
if form.Has("client_assertion") || form.Has("client_assertion_type") {
|
||||
t.Errorf("form should be untouched, got %v", form)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAuth_applyClientAssertion_PrivateKeyJWT(t *testing.T) {
|
||||
ca := ClientAuth{
|
||||
AppID: "cli_a",
|
||||
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
||||
Signer: newFakeAuthSigner(t),
|
||||
KeyLabel: "k",
|
||||
}
|
||||
form := url.Values{}
|
||||
used, err := ca.applyClientAssertion(context.Background(), form, "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !used {
|
||||
t.Fatal("expected client_assertion to be applied")
|
||||
}
|
||||
if form.Get("client_assertion_type") != jwt.ClientAssertionType {
|
||||
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
|
||||
}
|
||||
if form.Get("client_assertion") == "" {
|
||||
t.Error("client_assertion is empty")
|
||||
}
|
||||
if form.Has("client_secret") {
|
||||
t.Error("client_secret must NOT be present for private_key_jwt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAuth_applyClientAssertion_NilSigner(t *testing.T) {
|
||||
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT} // Signer nil
|
||||
if _, err := ca.applyClientAssertion(context.Background(), url.Values{}, "aud"); err == nil {
|
||||
t.Fatal("expected error when private_key_jwt has no signer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAuthFromConfig(t *testing.T) {
|
||||
ca := ClientAuthFromConfig(&core.CliConfig{
|
||||
AppID: "cli_x",
|
||||
AppSecret: "s",
|
||||
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
||||
KeyLabel: "label-1",
|
||||
})
|
||||
if ca.AppID != "cli_x" || ca.AppSecret != "s" || ca.AuthMethod != core.AuthMethodPrivateKeyJWT || ca.KeyLabel != "label-1" {
|
||||
t.Errorf("ClientAuth = %+v", ca)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
|
||||
}
|
||||
|
||||
// RequestDeviceAuthorization requests a device authorization code.
|
||||
func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
|
||||
func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
@@ -77,26 +77,18 @@ func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca
|
||||
}
|
||||
}
|
||||
|
||||
basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", ca.AppID)
|
||||
form.Set("client_id", appId)
|
||||
form.Set("scope", scope)
|
||||
|
||||
// private_key_jwt authenticates the client with a signed assertion in the
|
||||
// body; client_secret uses HTTP Basic.
|
||||
usedAssertion, err := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
|
||||
req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if !usedAssertion {
|
||||
basicAuth := base64.StdEncoding.EncodeToString([]byte(ca.AppID + ":" + ca.AppSecret))
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth)
|
||||
}
|
||||
req.Header.Set("Authorization", "Basic "+basicAuth)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -147,7 +139,7 @@ func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca
|
||||
}
|
||||
|
||||
// PollDeviceToken polls the token endpoint until authorization completes or times out.
|
||||
func PollDeviceToken(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
|
||||
func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
@@ -179,16 +171,10 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, ca ClientAuth
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
|
||||
form.Set("device_code", deviceCode)
|
||||
form.Set("client_id", ca.AppID)
|
||||
usedAssertion, caErr := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand))
|
||||
if caErr != nil {
|
||||
return &DeviceFlowResult{OK: false, Error: "invalid_client", Message: caErr.Error()}
|
||||
}
|
||||
if !usedAssertion {
|
||||
form.Set("client_secret", ca.AppSecret)
|
||||
}
|
||||
form.Set("client_id", appId)
|
||||
form.Set("client_secret", appSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoints.Token, strings.NewReader(form.Encode()))
|
||||
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -85,7 +83,7 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
_, err := RequestDeviceAuthorization(context.Background(), httpmock.NewClient(reg), ClientAuth{AppID: "cli_a", AppSecret: "secret_b"}, core.BrandFeishu, "", nil)
|
||||
_, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RequestDeviceAuthorization() error: %v", err)
|
||||
}
|
||||
@@ -108,66 +106,6 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// captureRT records the last request + body and returns a canned device-auth response.
|
||||
func captureDeviceAuthClient(gotReq **http.Request, gotBody *string, respJSON string) *http.Client {
|
||||
return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
*gotReq = req
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
*gotBody = string(b)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(respJSON)),
|
||||
}, nil
|
||||
})}
|
||||
}
|
||||
|
||||
const deviceAuthRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`
|
||||
|
||||
func TestRequestDeviceAuthorization_PrivateKeyJWT_UsesAssertionNotBasic(t *testing.T) {
|
||||
var req *http.Request
|
||||
var body string
|
||||
client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON)
|
||||
|
||||
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT, Signer: newFakeAuthSigner(t), KeyLabel: "k"}
|
||||
if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "im:message:send", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Header.Get("Authorization") != "" {
|
||||
t.Errorf("private_key_jwt must NOT send Basic auth, got %q", req.Header.Get("Authorization"))
|
||||
}
|
||||
form, _ := url.ParseQuery(body)
|
||||
if form.Get("client_assertion") == "" {
|
||||
t.Error("missing client_assertion")
|
||||
}
|
||||
if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
|
||||
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
|
||||
}
|
||||
if form.Has("client_secret") {
|
||||
t.Error("client_secret must not be present for private_key_jwt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestDeviceAuthorization_ClientSecret_UsesBasic(t *testing.T) {
|
||||
var req *http.Request
|
||||
var body string
|
||||
client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON)
|
||||
|
||||
ca := ClientAuth{AppID: "cli_a", AppSecret: "sec"} // client_secret
|
||||
if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(req.Header.Get("Authorization"), "Basic ") {
|
||||
t.Errorf("client_secret should use Basic auth, got %q", req.Header.Get("Authorization"))
|
||||
}
|
||||
form, _ := url.ParseQuery(body)
|
||||
if form.Has("client_assertion") {
|
||||
t.Error("client_secret must not send a client_assertion")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated.
|
||||
func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
|
||||
got := keychain.FormatAuthCmdline([]string{
|
||||
@@ -267,7 +205,7 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result := PollDeviceToken(ctx, client, ClientAuth{AppID: "cli_a", AppSecret: "secret_b"}, core.BrandFeishu, "device-code", 0, 10, nil)
|
||||
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
|
||||
if result == nil {
|
||||
t.Fatal("PollDeviceToken() returned nil result")
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package jwt builds compact JWS tokens signed by a keysigner.Signer.
|
||||
//
|
||||
// It deliberately depends only on the standard library plus the existing
|
||||
// google/uuid dependency — no third-party JWT library is introduced, keeping
|
||||
// go.mod free of new dependencies. The actual signing (and, for ECDSA, the
|
||||
// ASN.1->r||s conversion) is delegated to the Signer implementation.
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
// buildSignedJWT builds a compact JWS:
|
||||
//
|
||||
// base64url(header).base64url(claims).base64url(signature)
|
||||
//
|
||||
// alg is written into the header (it is part of the signed input) and verified
|
||||
// against the alg the signer reports, guarding against a header/key mismatch.
|
||||
// typ defaults to "JWT": the server's client_assertion generalizedValidation
|
||||
// REQUIRES `typ == "JWT"` (rejects otherwise with "malformed client assertion
|
||||
// jwt"), even though the spec examples (§8.1/§8.2) show only alg.
|
||||
func buildSignedJWT(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, alg string, header, claims map[string]any) (string, error) {
|
||||
if signer == nil {
|
||||
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
|
||||
}
|
||||
if header == nil {
|
||||
header = map[string]any{}
|
||||
}
|
||||
header["alg"] = alg
|
||||
if _, ok := header["typ"]; !ok {
|
||||
header["typ"] = "JWT"
|
||||
}
|
||||
|
||||
hb, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt: marshal header: %w", err)
|
||||
}
|
||||
cb, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt: marshal claims: %w", err)
|
||||
}
|
||||
|
||||
signingInput := b64(hb) + "." + b64(cb)
|
||||
sig, gotAlg, err := signer.Sign(ctx, ref, []byte(signingInput))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt: sign: %w", err)
|
||||
}
|
||||
if gotAlg != alg {
|
||||
return "", fmt.Errorf("jwt: signer alg %q does not match header alg %q", gotAlg, alg)
|
||||
}
|
||||
return signingInput + "." + b64(sig), nil
|
||||
}
|
||||
|
||||
// newJTI returns a random unique token identifier.
|
||||
func newJTI() string { return uuid.NewString() }
|
||||
|
||||
// attestationTTL bounds the attestation JWT's lifetime. The init nonce (60s,
|
||||
// single-use) is the real anti-replay constraint; this is a modest margin for
|
||||
// clock skew on top of the immediate init→sign→begin round-trip.
|
||||
const attestationTTL = 2 * time.Minute
|
||||
|
||||
// attestationClaims builds the registration attestation claim set per the App
|
||||
// Registration JWT spec: jti, iat, exp (all required) and the init-issued nonce.
|
||||
func attestationClaims(nonce string, now time.Time) map[string]any {
|
||||
return map[string]any{
|
||||
"jti": newJTI(),
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(attestationTTL).Unix(),
|
||||
"nonce": nonce,
|
||||
}
|
||||
}
|
||||
|
||||
// clientAssertionClaims builds an RFC 7523 client_assertion claim set used to
|
||||
// mint tokens in place of client_secret. aud is the brand's token endpoint URL.
|
||||
func clientAssertionClaims(clientID, aud string, now time.Time, ttl time.Duration) map[string]any {
|
||||
return map[string]any{
|
||||
"iss": clientID,
|
||||
"sub": clientID,
|
||||
"aud": aud,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(ttl).Unix(),
|
||||
"jti": newJTI(),
|
||||
}
|
||||
}
|
||||
|
||||
// ClientAssertionType is the RFC 7523 client_assertion_type value used for JWT
|
||||
// bearer client authentication at the token endpoint.
|
||||
const ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
||||
|
||||
// defaultAssertionTTL bounds a client_assertion's lifetime.
|
||||
const defaultAssertionTTL = 5 * time.Minute
|
||||
|
||||
// SignAttestation signs the registration attestation JWT. The public key is
|
||||
// embedded in the JWS "jwk" header so the registration backend can bind it to
|
||||
// the app during action=begin; the claims carry the server nonce as a
|
||||
// proof-of-possession challenge.
|
||||
func SignAttestation(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, nonce string, now time.Time) (string, error) {
|
||||
if signer == nil {
|
||||
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
|
||||
}
|
||||
pub, err := signer.EnsureKey(ctx, ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt: ensure key: %w", err)
|
||||
}
|
||||
alg, err := keysigner.AlgForKey(pub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
jwk, err := keysigner.PublicKeyJWK(pub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buildSignedJWT(ctx, signer, ref, alg, map[string]any{"jwk": jwk}, attestationClaims(nonce, now))
|
||||
}
|
||||
|
||||
// SignClientAssertion mints a short-lived RFC 7523 client_assertion: it reads the
|
||||
// registered key (it must already exist — bound at registration; a missing key is
|
||||
// an error, not a reason to create a new unbound one), derives the JWS alg from
|
||||
// the public key, and signs an assertion whose audience is the brand's Open API
|
||||
// host. The server, holding the public key bound at registration, verifies it in
|
||||
// place of client_secret. The assertion header carries only alg (no jwk/kid);
|
||||
// the server locates the key via iss/sub = client_id.
|
||||
//
|
||||
// This is the model-independent glue: the assertion JWT is identical whether the
|
||||
// server augments an existing grant (device_code/refresh_token) with client
|
||||
// authentication or uses a dedicated jwt-bearer grant — only where the caller
|
||||
// attaches it differs.
|
||||
func SignClientAssertion(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, clientID, audience string, now time.Time) (string, error) {
|
||||
if signer == nil {
|
||||
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
|
||||
}
|
||||
pub, err := signer.PublicKey(ctx, ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt: public key: %w", err)
|
||||
}
|
||||
alg, err := keysigner.AlgForKey(pub)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buildSignedJWT(ctx, signer, ref, alg, map[string]any{}, clientAssertionClaims(clientID, audience, now, defaultAssertionTTL))
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// fakeSigner is a real in-memory ECDSA P-256 signer, so tests exercise the full
|
||||
// JWS path and the produced token is actually cryptographically verifiable.
|
||||
type fakeSigner struct{ key *ecdsa.PrivateKey }
|
||||
|
||||
func newFakeSigner(t *testing.T) *fakeSigner {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fakeSigner{key: k}
|
||||
}
|
||||
|
||||
func (f *fakeSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return f.key.Public(), nil
|
||||
}
|
||||
func (f *fakeSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return f.key.Public(), nil
|
||||
}
|
||||
func (f *fakeSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
|
||||
h := sha256.Sum256(in)
|
||||
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// JOSE ES256: fixed-width big-endian r||s (32 bytes each for P-256).
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
s.FillBytes(sig[32:])
|
||||
return sig, keysigner.AlgES256, nil
|
||||
}
|
||||
|
||||
func TestBuildSignedJWT_VerifiableES256(t *testing.T) {
|
||||
f := newFakeSigner(t)
|
||||
now := time.Unix(1700000000, 0)
|
||||
|
||||
tok, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{Label: "x"}, keysigner.AlgES256,
|
||||
map[string]any{}, clientAssertionClaims("cli_app", "https://accounts.example/token", now, 5*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
parts := strings.Split(tok, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("want 3 JWS parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
hb, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
t.Fatalf("header not base64url: %v", err)
|
||||
}
|
||||
var hdr map[string]any
|
||||
if err := json.Unmarshal(hb, &hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hdr["alg"] != "ES256" || hdr["typ"] != "JWT" {
|
||||
t.Errorf("header = %v, want alg=ES256 typ=JWT (server generalizedValidation requires typ)", hdr)
|
||||
}
|
||||
|
||||
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(cb, &claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims["iss"] != "cli_app" || claims["sub"] != "cli_app" || claims["aud"] != "https://accounts.example/token" {
|
||||
t.Errorf("claims = %v", claims)
|
||||
}
|
||||
|
||||
// Cryptographically verify the signature against the signing input.
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
t.Fatalf("sig not base64url: %v", err)
|
||||
}
|
||||
if len(sig) != 64 {
|
||||
t.Fatalf("ES256 sig len = %d, want 64", len(sig))
|
||||
}
|
||||
r := new(big.Int).SetBytes(sig[:32])
|
||||
s := new(big.Int).SetBytes(sig[32:])
|
||||
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
|
||||
t.Error("signature did not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSignedJWT_NilSigner(t *testing.T) {
|
||||
if _, err := buildSignedJWT(context.Background(), nil, keysigner.KeyRef{}, "ES256", nil, nil); err == nil {
|
||||
t.Fatal("expected error for nil signer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSignedJWT_AlgMismatch(t *testing.T) {
|
||||
f := newFakeSigner(t) // always reports ES256
|
||||
if _, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{}, keysigner.AlgRS256, nil, nil); err == nil {
|
||||
t.Fatal("expected error when header alg != signer alg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSignedJWT_MarshalErrors(t *testing.T) {
|
||||
f := newFakeSigner(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256,
|
||||
map[string]any{"bad": func() {}}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "jwt: marshal header") {
|
||||
t.Fatalf("header marshal error = %v, want prefix %q", err, "jwt: marshal header")
|
||||
}
|
||||
|
||||
_, err = buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256,
|
||||
nil, map[string]any{"bad": make(chan int)})
|
||||
if err == nil || !strings.Contains(err.Error(), "jwt: marshal claims") {
|
||||
t.Fatalf("claims marshal error = %v, want prefix %q", err, "jwt: marshal claims")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignClientAssertion(t *testing.T) {
|
||||
f := newFakeSigner(t)
|
||||
now := time.Unix(1700000000, 0)
|
||||
const aud = "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token"
|
||||
|
||||
tok, err := SignClientAssertion(context.Background(), f, keysigner.KeyRef{Label: "k"}, "cli_app", aud, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
parts := strings.Split(tok, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("want 3 parts, got %d", len(parts))
|
||||
}
|
||||
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(cb, &claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims["iss"] != "cli_app" || claims["aud"] != aud {
|
||||
t.Errorf("claims = %v", claims)
|
||||
}
|
||||
|
||||
// Signature must verify against the key's public half.
|
||||
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
r := new(big.Int).SetBytes(sig[:32])
|
||||
s := new(big.Int).SetBytes(sig[32:])
|
||||
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
|
||||
t.Error("client_assertion signature did not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignClientAssertion_NilSigner(t *testing.T) {
|
||||
if _, err := SignClientAssertion(context.Background(), nil, keysigner.KeyRef{}, "cli_app", "aud", time.Unix(0, 0)); err == nil {
|
||||
t.Fatal("expected error for nil signer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignAttestation(t *testing.T) {
|
||||
f := newFakeSigner(t)
|
||||
now := time.Unix(1700000000, 0)
|
||||
|
||||
tok, err := SignAttestation(context.Background(), f, keysigner.KeyRef{Label: "k"}, "nonce-abc", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(tok, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("want 3 parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
hb, _ := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
var hdr map[string]any
|
||||
if err := json.Unmarshal(hb, &hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwk, ok := hdr["jwk"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("attestation header missing jwk: %v", hdr)
|
||||
}
|
||||
if jwk["kty"] != "EC" || jwk["crv"] != "P-256" || jwk["use"] != "sig" {
|
||||
t.Errorf("jwk = %v", jwk)
|
||||
}
|
||||
|
||||
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(cb, &claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims["nonce"] != "nonce-abc" {
|
||||
t.Errorf("nonce = %v", claims["nonce"])
|
||||
}
|
||||
// jti, iat, exp are all required by the attestation spec.
|
||||
iat, iatOK := claims["iat"].(float64)
|
||||
exp, expOK := claims["exp"].(float64)
|
||||
if !iatOK || !expOK || exp <= iat {
|
||||
t.Errorf("claims iat/exp invalid: iat=%v exp=%v", claims["iat"], claims["exp"])
|
||||
}
|
||||
if jti, _ := claims["jti"].(string); jti == "" {
|
||||
t.Error("claims jti empty")
|
||||
}
|
||||
|
||||
// Signature verifies against the embedded key.
|
||||
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
r := new(big.Int).SetBytes(sig[:32])
|
||||
s := new(big.Int).SetBytes(sig[32:])
|
||||
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
||||
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
|
||||
t.Error("attestation signature did not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignAttestation_NilSigner(t *testing.T) {
|
||||
if _, err := SignAttestation(context.Background(), nil, keysigner.KeyRef{}, "n", time.Unix(0, 0)); err == nil {
|
||||
t.Fatal("expected error for nil signer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimFactories(t *testing.T) {
|
||||
now := time.Unix(1700000000, 0)
|
||||
|
||||
a := attestationClaims("nonce-xyz", now)
|
||||
if a["nonce"] != "nonce-xyz" || a["iat"] != now.Unix() {
|
||||
t.Errorf("attestation claims = %v", a)
|
||||
}
|
||||
if a["exp"] != now.Add(attestationTTL).Unix() {
|
||||
t.Errorf("attestation exp = %v, want %v", a["exp"], now.Add(attestationTTL).Unix())
|
||||
}
|
||||
if jti, _ := a["jti"].(string); jti == "" {
|
||||
t.Error("attestation jti empty")
|
||||
}
|
||||
|
||||
c := clientAssertionClaims("cli_app", "aud", now, time.Minute)
|
||||
if c["exp"].(int64) != now.Add(time.Minute).Unix() {
|
||||
t.Errorf("client_assertion exp = %v", c["exp"])
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -38,10 +37,7 @@ type UATCallOptions struct {
|
||||
AppId string
|
||||
AppSecret string
|
||||
Domain core.LarkBrand
|
||||
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
|
||||
KeyLabel string // TEE key handle for private_key_jwt
|
||||
Signer keysigner.Signer // active signer for private_key_jwt
|
||||
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
|
||||
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
|
||||
}
|
||||
|
||||
// UATStatus represents the status of a user access token.
|
||||
@@ -65,9 +61,6 @@ func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions {
|
||||
AppId: cfg.AppID,
|
||||
AppSecret: cfg.AppSecret,
|
||||
Domain: cfg.Brand,
|
||||
AuthMethod: cfg.AuthMethod,
|
||||
KeyLabel: cfg.KeyLabel,
|
||||
Signer: keysigner.Active(),
|
||||
ErrOut: errOut,
|
||||
}
|
||||
}
|
||||
@@ -200,14 +193,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", stored.RefreshToken)
|
||||
form.Set("client_id", opts.AppId)
|
||||
ca := ClientAuth{AppID: opts.AppId, AppSecret: opts.AppSecret, AuthMethod: opts.AuthMethod, Signer: opts.Signer, KeyLabel: opts.KeyLabel}
|
||||
usedAssertion, caErr := ca.applyClientAssertion(context.Background(), form, core.OpenAPIAudience(opts.Domain))
|
||||
if caErr != nil {
|
||||
return nil, caErr
|
||||
}
|
||||
if !usedAssertion {
|
||||
form.Set("client_secret", opts.AppSecret)
|
||||
}
|
||||
form.Set("client_secret", opts.AppSecret)
|
||||
|
||||
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
|
||||
@@ -38,23 +38,3 @@ func TestNewUATCallOptions(t *testing.T) {
|
||||
t.Error("ErrOut not set correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewUATCallOptions_PrivateKeyJWT verifies the auth-method fields propagate
|
||||
// so the refresh path can mint a client_assertion instead of sending a secret.
|
||||
func TestNewUATCallOptions_PrivateKeyJWT(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "cli_pk",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
||||
KeyLabel: "agent-key",
|
||||
}
|
||||
opts := NewUATCallOptions(cfg, &bytes.Buffer{})
|
||||
|
||||
if opts.AuthMethod != core.AuthMethodPrivateKeyJWT {
|
||||
t.Errorf("AuthMethod = %q, want private_key_jwt", opts.AuthMethod)
|
||||
}
|
||||
if opts.KeyLabel != "agent-key" {
|
||||
t.Errorf("KeyLabel = %q, want agent-key", opts.KeyLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,16 +42,6 @@ func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
|
||||
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
|
||||
}
|
||||
|
||||
// StdoutIsTerminal reports whether Out is an interactive terminal. Unlike
|
||||
// IsTerminal — which reflects stdin and drives prompt decisions — this is the
|
||||
// correct check for OUTPUT formatting: `cmd | jq` must still emit machine output
|
||||
// from an interactive shell (stdin is a TTY there, but stdout is the pipe).
|
||||
// Buffers (tests) and redirects are not *os.File terminals, so they yield false.
|
||||
func (s *IOStreams) StdoutIsTerminal() bool {
|
||||
f, ok := s.Out.(*os.File)
|
||||
return ok && term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
|
||||
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
|
||||
//
|
||||
//nolint:forbidigo // entry point for real stdio
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStdoutIsTerminal(t *testing.T) {
|
||||
// Buffer-backed output (tests, captured output) is never a terminal.
|
||||
if (&IOStreams{Out: &bytes.Buffer{}}).StdoutIsTerminal() {
|
||||
t.Error("bytes.Buffer Out should not be a terminal")
|
||||
}
|
||||
// An os.Pipe write end is an *os.File but not a terminal — mirrors `cmd | jq`,
|
||||
// the case the stdin-based IsTerminal would get wrong.
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
if (&IOStreams{Out: w}).StdoutIsTerminal() {
|
||||
t.Error("os.Pipe Out should not be a terminal")
|
||||
}
|
||||
}
|
||||
@@ -36,13 +36,6 @@ type AppUser struct {
|
||||
UserName string `json:"userName"`
|
||||
}
|
||||
|
||||
// Auth methods for app credentials. An empty AppConfig.AuthMethod means the
|
||||
// default, client_secret.
|
||||
const (
|
||||
AuthMethodClientSecret = "client_secret" // app_id + app_secret
|
||||
AuthMethodPrivateKeyJWT = "private_key_jwt" // TEE-signed client_assertion; no app secret
|
||||
)
|
||||
|
||||
// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved).
|
||||
type AppConfig struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
@@ -53,15 +46,6 @@ type AppConfig struct {
|
||||
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
|
||||
StrictMode *StrictMode `json:"strictMode,omitempty"`
|
||||
Users []AppUser `json:"users"`
|
||||
|
||||
// AuthMethod selects how tokens are minted. Empty == AuthMethodClientSecret
|
||||
// (back-compat). AuthMethodPrivateKeyJWT uses a TEE-held key (see KeyRef) to
|
||||
// sign client_assertion JWTs instead of sending an app secret.
|
||||
AuthMethod string `json:"authMethod,omitempty"`
|
||||
// KeyRef references the non-exportable signing key for private_key_jwt.
|
||||
// Source is "tee" and ID is the backend key label; the actual key never
|
||||
// leaves the secure backend, so this is a handle, not secret material.
|
||||
KeyRef *SecretRef `json:"keyRef,omitempty"`
|
||||
}
|
||||
|
||||
// ProfileName returns the display name for this app config.
|
||||
@@ -177,9 +161,7 @@ type CliConfig struct {
|
||||
UserOpenId string
|
||||
UserName string
|
||||
Lang i18n.Lang
|
||||
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
|
||||
AuthMethod string // "" == client_secret; AuthMethodPrivateKeyJWT
|
||||
KeyLabel string // resolved TEE key handle for private_key_jwt
|
||||
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
|
||||
}
|
||||
|
||||
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
|
||||
@@ -265,58 +247,31 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
WithHint("available profiles: %s", formatProfileNames(raw.ProfileNames()))
|
||||
}
|
||||
|
||||
// Validate the auth method first so a malformed profile fails here rather
|
||||
// than silently degrading to client_secret (unknown method) or failing later
|
||||
// at token-signing. Empty stays empty — downstream treats it as client_secret
|
||||
// (back-compat).
|
||||
switch app.AuthMethod {
|
||||
case "", AuthMethodClientSecret, AuthMethodPrivateKeyJWT:
|
||||
default:
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "unknown authMethod %q", app.AuthMethod).
|
||||
WithHint("supported: %s, %s (empty defaults to %s)", AuthMethodClientSecret, AuthMethodPrivateKeyJWT, AuthMethodClientSecret)
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
|
||||
WithHint("%s", err.Error()).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// private_key_jwt carries no secret: validate the key handle and skip secret
|
||||
// resolution entirely, so a stale/broken AppSecret ref never produces a
|
||||
// confusing secret-resolution error for an otherwise-valid pkjwt profile.
|
||||
var secret string
|
||||
if app.AuthMethod == AuthMethodPrivateKeyJWT {
|
||||
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID == "" {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "private_key_jwt requires a key handle (keyRef) but none is configured").
|
||||
WithHint("re-run: lark-cli config init --new --auth-method private_key_jwt")
|
||||
secret, err := ResolveSecretInput(app.AppSecret, kc)
|
||||
if err != nil {
|
||||
if errs.IsTyped(err) {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
|
||||
WithHint("%s", err.Error()).
|
||||
WithCause(err)
|
||||
}
|
||||
var resolveErr error
|
||||
secret, resolveErr = ResolveSecretInput(app.AppSecret, kc)
|
||||
if resolveErr != nil {
|
||||
if errs.IsTyped(resolveErr) {
|
||||
return nil, resolveErr
|
||||
}
|
||||
subtype := errs.SubtypeNotConfigured
|
||||
if isMalformedConfigError(resolveErr) {
|
||||
subtype = errs.SubtypeInvalidConfig
|
||||
}
|
||||
return nil, errs.NewConfigError(subtype, "%s", resolveErr.Error()).WithCause(resolveErr)
|
||||
subtype := errs.SubtypeNotConfigured
|
||||
if isMalformedConfigError(err) {
|
||||
subtype = errs.SubtypeInvalidConfig
|
||||
}
|
||||
return nil, errs.NewConfigError(subtype, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
|
||||
cfg := &CliConfig{
|
||||
ProfileName: app.ProfileName(),
|
||||
AppID: app.AppId,
|
||||
AppSecret: secret,
|
||||
Brand: app.Brand,
|
||||
Lang: app.Lang,
|
||||
AuthMethod: app.AuthMethod,
|
||||
DefaultAs: app.DefaultAs,
|
||||
}
|
||||
if app.KeyRef != nil {
|
||||
cfg.KeyLabel = app.KeyRef.ID
|
||||
}
|
||||
if len(app.Users) > 0 {
|
||||
cfg.UserOpenId = app.Users[0].UserOpenId
|
||||
cfg.UserName = app.Users[0].UserName
|
||||
|
||||
@@ -133,108 +133,6 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveConfigFromMulti_RejectsUnknownAuthMethod ensures an unsupported
|
||||
// authMethod fails at resolution rather than silently degrading to client_secret.
|
||||
func TestResolveConfigFromMulti_RejectsUnknownAuthMethod(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
AppId: "cli_abc",
|
||||
AppSecret: PlainSecret("my-secret"),
|
||||
Brand: BrandFeishu,
|
||||
AuthMethod: "bogus_method",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown authMethod")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef ensures private_key_jwt
|
||||
// without a key handle fails at resolution rather than later at token-signing.
|
||||
func TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{
|
||||
{
|
||||
AppId: "cli_abc",
|
||||
AppSecret: SecretInput{}, // private_key_jwt carries no app secret
|
||||
Brand: BrandFeishu,
|
||||
AuthMethod: AuthMethodPrivateKeyJWT,
|
||||
// KeyRef intentionally nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for private_key_jwt without keyRef")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
// Control: same config WITH a keyRef resolves cleanly and sets KeyLabel.
|
||||
raw.Apps[0].KeyRef = &SecretRef{Source: "tee", ID: "larksuite-cli-agent"}
|
||||
cfg, err := ResolveConfigFromMulti(raw, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error with keyRef present: %v", err)
|
||||
}
|
||||
if cfg.KeyLabel != "larksuite-cli-agent" {
|
||||
t.Errorf("KeyLabel = %q, want larksuite-cli-agent", cfg.KeyLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveConfigFromMulti_PKJWTSkipsSecretResolution ensures a private_key_jwt
|
||||
// profile that carries a stale/broken AppSecret ref still resolves cleanly: the
|
||||
// auth method is judged before any secret handling, so the stale ref is ignored
|
||||
// instead of producing a confusing secret-resolution failure.
|
||||
func TestResolveConfigFromMulti_PKJWTSkipsSecretResolution(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{{
|
||||
AppId: "cli_pk",
|
||||
// Stale keychain ref whose ID does not match appId — would trip
|
||||
// ValidateSecretKeyMatch / ResolveSecretInput if it were reached.
|
||||
AppSecret: SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_OTHER"}},
|
||||
Brand: BrandFeishu,
|
||||
AuthMethod: AuthMethodPrivateKeyJWT,
|
||||
KeyRef: &SecretRef{Source: "tee", ID: "agent-key"},
|
||||
Users: []AppUser{},
|
||||
}},
|
||||
}
|
||||
cfg, err := ResolveConfigFromMulti(raw, stubKeychain{}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("pkjwt with stale secret ref must skip secret resolution, got %v", err)
|
||||
}
|
||||
if cfg.AuthMethod != AuthMethodPrivateKeyJWT || cfg.KeyLabel != "agent-key" {
|
||||
t.Errorf("got authMethod=%q keyLabel=%q", cfg.AuthMethod, cfg.KeyLabel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef ensures the stricter keyRef
|
||||
// check (Source=="tee" && ID!="") rejects malformed handles.
|
||||
func TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef(t *testing.T) {
|
||||
for i, ref := range []*SecretRef{
|
||||
{Source: "keychain", ID: "x"}, // wrong source
|
||||
{Source: "tee", ID: ""}, // empty id
|
||||
} {
|
||||
raw := &MultiAppConfig{Apps: []AppConfig{{
|
||||
AppId: "cli_pk", Brand: BrandFeishu,
|
||||
AuthMethod: AuthMethodPrivateKeyJWT, KeyRef: ref, Users: []AppUser{},
|
||||
}}}
|
||||
if _, err := ResolveConfigFromMulti(raw, stubKeychain{}, ""); err == nil {
|
||||
t.Errorf("case %d: expected ConfigError for bad keyRef", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigFromMulti_CarriesLang(t *testing.T) {
|
||||
raw := &MultiAppConfig{
|
||||
Apps: []AppConfig{
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
// Any other string is treated as a custom base URL.
|
||||
@@ -62,10 +60,3 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
func ResolveOpenBaseURL(brand LarkBrand) string {
|
||||
return ResolveEndpoints(brand).Open
|
||||
}
|
||||
|
||||
// OpenAPIAudience returns the client_assertion `aud` value for the brand: the
|
||||
// bare Open API host per the App Authentication JWT spec — "open.feishu.cn" or
|
||||
// "open.larksuite.com" — not the full token endpoint URL.
|
||||
func OpenAPIAudience(brand LarkBrand) string {
|
||||
return strings.TrimPrefix(ResolveOpenBaseURL(brand), "https://")
|
||||
}
|
||||
|
||||
@@ -57,12 +57,3 @@ func TestResolveOpenBaseURL(t *testing.T) {
|
||||
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPIAudience(t *testing.T) {
|
||||
if got := OpenAPIAudience(BrandFeishu); got != "open.feishu.cn" {
|
||||
t.Errorf("OpenAPIAudience(feishu) = %q, want open.feishu.cn", got)
|
||||
}
|
||||
if got := OpenAPIAudience(BrandLark); got != "open.larksuite.com" {
|
||||
t.Errorf("OpenAPIAudience(lark) = %q, want open.larksuite.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
|
||||
@@ -176,23 +175,6 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// private_key_jwt apps have no app secret: mint via the jwt-bearer grant
|
||||
// using a TEE-signed client_assertion instead.
|
||||
if acct.AuthMethod == core.AuthMethodPrivateKeyJWT {
|
||||
signer := keysigner.Active()
|
||||
if signer == nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient,
|
||||
"profile uses private_key_jwt but no TEE key signer is available on this build").
|
||||
WithHint("install a build with the platform key-signer extension, or reconfigure the app to use an app secret")
|
||||
}
|
||||
token, err := FetchTATWithAssertion(ctx, httpClient, acct.Brand, acct.AppID, signer, acct.KeyLabel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TokenResult{Token: token}, nil
|
||||
}
|
||||
|
||||
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,13 +11,8 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/auth/jwt"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
|
||||
@@ -105,96 +100,3 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
|
||||
}
|
||||
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
|
||||
}
|
||||
|
||||
// FetchTATWithAssertion mints a tenant access token for a private_key_jwt app via
|
||||
// the RFC 7523 jwt-bearer grant: it signs a short-lived client_assertion with the
|
||||
// TEE-held key and posts it to the unified OAuth token endpoint, replacing the
|
||||
// app_secret entirely.
|
||||
//
|
||||
// The unified v2 token endpoint returns the minted token as access_token
|
||||
// (tenant_access_token is accepted as a fallback).
|
||||
func FetchTATWithAssertion(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) (string, error) {
|
||||
if signer == nil {
|
||||
return "", fmt.Errorf("private_key_jwt requires a key signer, but none is available on this build")
|
||||
}
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
endpoint := ep.Open + auth.PathOAuthTokenV2
|
||||
|
||||
assertion, err := jwt.SignClientAssertion(ctx, signer, keysigner.KeyRef{Label: keyLabel}, clientID, core.OpenAPIAudience(brand), time.Now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||
form.Set("client_id", clientID)
|
||||
form.Set("client_assertion_type", jwt.ClientAssertionType)
|
||||
form.Set("client_assertion", assertion)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read token response: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
AccessToken string `json:"access_token"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &result) // best-effort; error body may not be JSON
|
||||
|
||||
token := result.AccessToken
|
||||
if token == "" {
|
||||
token = result.TenantAccessToken
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK && token != "" && result.Error == "" && result.Code == 0 {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Surface the server's reason, preferring the OAuth `error` code (e.g.
|
||||
// unauthorized_client) which is more diagnostic than the description alone.
|
||||
detail := result.ErrorDescription
|
||||
if detail == "" {
|
||||
detail = result.Msg
|
||||
}
|
||||
if detail == "" {
|
||||
detail = strings.TrimSpace(string(body))
|
||||
}
|
||||
if result.Error != "" {
|
||||
return "", classifyAssertionError(result.Error, resp.StatusCode, detail)
|
||||
}
|
||||
return "", fmt.Errorf("token endpoint HTTP %d (code=%d): %s", resp.StatusCode, result.Code, detail)
|
||||
}
|
||||
|
||||
// classifyAssertionError maps the OAuth token endpoint's `error` field to a
|
||||
// typed or untyped error. Only deterministic client-credential rejections get a
|
||||
// typed errs.ConfigError (so runProbePKJWT can tell "this key is not bound to
|
||||
// this app" apart from upstream noise); every other error (e.g.
|
||||
// temporarily_unavailable) stays untyped and is swallowed by the probe. detail
|
||||
// carries only the server's error_description / msg / body text — it never
|
||||
// echoes the client_assertion or private key (the assertion lives only in the
|
||||
// request form).
|
||||
func classifyAssertionError(oauthError string, httpStatus int, detail string) error {
|
||||
switch oauthError {
|
||||
case "invalid_client", "unauthorized_client", "invalid_grant":
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient,
|
||||
"token endpoint rejected the key (%s): %s", oauthError, detail)
|
||||
default:
|
||||
return fmt.Errorf("token endpoint HTTP %d (%s): %s", httpStatus, oauthError, detail)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,15 @@ package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/keysigner"
|
||||
)
|
||||
|
||||
// stubRoundTripper lets us assert request shape and return canned responses.
|
||||
@@ -316,147 +307,3 @@ func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req2.Header = req.Header
|
||||
return http.DefaultTransport.RoundTrip(req2)
|
||||
}
|
||||
|
||||
// fakeTATSigner is a real in-memory ECDSA P-256 signer for assertion tests.
|
||||
type fakeTATSigner struct{ key *ecdsa.PrivateKey }
|
||||
|
||||
func newFakeTATSigner(t *testing.T) *fakeTATSigner {
|
||||
t.Helper()
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &fakeTATSigner{key: k}
|
||||
}
|
||||
|
||||
func (f *fakeTATSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return f.key.Public(), nil
|
||||
}
|
||||
func (f *fakeTATSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
|
||||
return f.key.Public(), nil
|
||||
}
|
||||
func (f *fakeTATSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
|
||||
h := sha256.Sum256(in)
|
||||
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
s.FillBytes(sig[32:])
|
||||
return sig, keysigner.AlgES256, nil
|
||||
}
|
||||
|
||||
func TestFetchTATWithAssertion_Success(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"access_token":"t-jwt","token_type":"Bearer","expires_in":7200}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "agent-key")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "t-jwt" {
|
||||
t.Errorf("token = %q, want t-jwt", token)
|
||||
}
|
||||
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
|
||||
t.Errorf("url = %s", rt.gotReq.URL.String())
|
||||
}
|
||||
|
||||
form, err := url.ParseQuery(rt.gotBody)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:jwt-bearer" {
|
||||
t.Errorf("grant_type = %q", form.Get("grant_type"))
|
||||
}
|
||||
if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
|
||||
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
|
||||
}
|
||||
if form.Get("client_assertion") == "" {
|
||||
t.Error("client_assertion is empty")
|
||||
}
|
||||
if form.Has("client_secret") {
|
||||
t.Error("client_secret must NOT be sent for private_key_jwt")
|
||||
}
|
||||
|
||||
// The assertion's aud must be the bare Open host per the App Authentication
|
||||
// JWT spec — not the full token endpoint URL.
|
||||
jwtParts := strings.Split(form.Get("client_assertion"), ".")
|
||||
if len(jwtParts) != 3 {
|
||||
t.Fatalf("malformed client_assertion: %q", form.Get("client_assertion"))
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(jwtParts[1])
|
||||
if err != nil {
|
||||
t.Fatalf("assertion payload not base64url: %v", err)
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims["aud"] != "open.feishu.cn" {
|
||||
t.Errorf("client_assertion aud = %v, want open.feishu.cn", claims["aud"])
|
||||
}
|
||||
if claims["iss"] != "cli_app" || claims["sub"] != "cli_app" {
|
||||
t.Errorf("client_assertion iss/sub = %v/%v, want cli_app", claims["iss"], claims["sub"])
|
||||
}
|
||||
if form.Get("client_id") != "cli_app" {
|
||||
t.Errorf("client_id = %q", form.Get("client_id"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTATWithAssertion_NilSigner(t *testing.T) {
|
||||
hc := &http.Client{Transport: &stubRoundTripper{respCode: 200, respBody: `{}`}}
|
||||
if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", nil, "k"); err == nil {
|
||||
t.Fatal("expected error when signer is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTATWithAssertion_ServerError(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"error":"invalid_client","error_description":"unknown key"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k"); err == nil {
|
||||
t.Fatal("expected error for invalid_client response")
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic OAuth client rejections must be typed (ConfigError /
|
||||
// SubtypeInvalidClient) so runProbePKJWT can tell "the key is not bound to this
|
||||
// app" apart from transport noise.
|
||||
func TestFetchTATWithAssertion_DeterministicReject_Typed(t *testing.T) {
|
||||
for _, oauthErr := range []string{"invalid_client", "unauthorized_client", "invalid_grant"} {
|
||||
rt := &stubRoundTripper{respCode: 401, respBody: `{"error":"` + oauthErr + `","error_description":"bad key"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected error", oauthErr)
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Errorf("%s: must be typed, got %T", oauthErr, err)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) || cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("%s: want ConfigError/InvalidClient, got %T %v", oauthErr, err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unrecognized OAuth errors and non-payload noise stay UNTYPED so the probe
|
||||
// treats them as upstream noise and stays silent.
|
||||
func TestFetchTATWithAssertion_AmbiguousError_Untyped(t *testing.T) {
|
||||
cases := []string{
|
||||
`{"error":"temporarily_unavailable","error_description":"retry"}`,
|
||||
`{"code":99999,"msg":"weird"}`,
|
||||
`not json`,
|
||||
}
|
||||
for _, body := range cases {
|
||||
rt := &stubRoundTripper{respCode: 503, respBody: body}
|
||||
hc := &http.Client{Transport: rt}
|
||||
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
|
||||
if err == nil {
|
||||
t.Fatalf("body %q: expected error", body)
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("body %q: must be UNTYPED, got typed %T", body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ type Account struct {
|
||||
UserName string
|
||||
Lang i18n.Lang
|
||||
SupportedIdentities uint8
|
||||
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
|
||||
KeyLabel string // resolved TEE key handle for private_key_jwt
|
||||
}
|
||||
|
||||
const runtimePlaceholderAppSecret = "__LARKSUITE_CLI_TOKEN_ONLY__"
|
||||
@@ -71,8 +69,6 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account {
|
||||
UserName: cfg.UserName,
|
||||
Lang: cfg.Lang,
|
||||
SupportedIdentities: cfg.SupportedIdentities,
|
||||
AuthMethod: cfg.AuthMethod,
|
||||
KeyLabel: cfg.KeyLabel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +87,6 @@ func (a *Account) ToCliConfig() *core.CliConfig {
|
||||
UserName: a.UserName,
|
||||
Lang: a.Lang,
|
||||
SupportedIdentities: a.SupportedIdentities,
|
||||
AuthMethod: a.AuthMethod,
|
||||
KeyLabel: a.KeyLabel,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,9 +82,7 @@ func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, v
|
||||
Hint: "check strict mode or the active credential provider",
|
||||
}
|
||||
}
|
||||
// private_key_jwt apps have no app secret — the bot/tenant token is minted via
|
||||
// a TEE-signed client_assertion — so absence of a secret is NOT "unconfigured".
|
||||
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) && cfg.AuthMethod != core.AuthMethodPrivateKeyJWT {
|
||||
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) {
|
||||
return Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (missing app secret or bot token)",
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package keysigner defines the pluggable signing abstraction used by the
|
||||
// private_key_jwt registration and authentication flow.
|
||||
//
|
||||
// The open-source core only declares the Signer interface and pure-stdlib key
|
||||
// helpers. The platform implementations that hold a non-exportable private key
|
||||
// (TPM 2.0 via facebookincubator/sks on Linux/Windows, a non-extractable
|
||||
// Keychain key on macOS) live OUTSIDE this core — in a build-tagged module or
|
||||
// extension — and register themselves via Register from init(). This keeps
|
||||
// CGO-heavy and license-sensitive dependencies out of the open-source build.
|
||||
package keysigner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KeyRef identifies a non-exportable signing key held by a backend
|
||||
// (TEE/TPM/Keychain). It is a stable handle (label), never the key material.
|
||||
type KeyRef struct {
|
||||
// Label is the backend key label/tag (e.g. "larksuite-cli-agent").
|
||||
Label string
|
||||
}
|
||||
|
||||
// Signer signs JWS signing inputs with a non-exportable key.
|
||||
type Signer interface {
|
||||
// EnsureKey returns the public key for ref, creating the key if absent.
|
||||
EnsureKey(ctx context.Context, ref KeyRef) (crypto.PublicKey, error)
|
||||
// PublicKey returns the public key for ref without creating it.
|
||||
PublicKey(ctx context.Context, ref KeyRef) (crypto.PublicKey, error)
|
||||
// Sign signs signingInput and returns a JOSE-format signature plus the JWS
|
||||
// alg ("ES256"/"RS256"). Implementations apply the alg's hash and, for
|
||||
// ECDSA, MUST return the fixed-width r||s form required by RFC 7518 §3.4
|
||||
// (not ASN.1 DER), because the backend (TPM/Keychain) typically yields DER.
|
||||
Sign(ctx context.Context, ref KeyRef, signingInput []byte) (sig []byte, alg string, err error)
|
||||
}
|
||||
|
||||
// Supported JWS algorithms.
|
||||
const (
|
||||
AlgES256 = "ES256"
|
||||
AlgRS256 = "RS256"
|
||||
)
|
||||
|
||||
// DefaultKeyLabel is the backend key label lark-cli uses for its device signing
|
||||
// key. One non-exportable key is created on first private_key_jwt registration
|
||||
// and reused across subsequent app registrations on the same device.
|
||||
const DefaultKeyLabel = "larksuite-cli-agent"
|
||||
|
||||
// HardwareInfo describes the secure hardware backing a Signer, as reported by a
|
||||
// HardwareProber. It is advisory/diagnostic: it tells a user whether
|
||||
// private_key_jwt can use a real TEE on this device.
|
||||
type HardwareInfo struct {
|
||||
Backend string // backing technology, e.g. "tpm2" or "keychain"
|
||||
Available bool // the hardware is present and usable for signing
|
||||
VendorName string // hardware vendor/manufacturer, when known
|
||||
VendorInfo string // additional vendor detail, when known
|
||||
Reason string // when Available is false, a human-readable cause
|
||||
}
|
||||
|
||||
// HardwareProber is an optional capability a Signer may implement to report on
|
||||
// the secure hardware backing it (TPM/TEE vendor and availability) WITHOUT
|
||||
// creating or using a key. Probing never mutates key state.
|
||||
type HardwareProber interface {
|
||||
ProbeHardware(ctx context.Context) (HardwareInfo, error)
|
||||
}
|
||||
|
||||
// ProbeActiveHardware probes the active signer's secure hardware. ok is false
|
||||
// when there is no active signer or it does not implement HardwareProber — in
|
||||
// which case private_key_jwt is unsupported on this build. When ok is true, info
|
||||
// reports availability and, if unavailable, info.Reason explains why.
|
||||
func ProbeActiveHardware(ctx context.Context) (info HardwareInfo, ok bool, err error) {
|
||||
return probeHardware(ctx, Active())
|
||||
}
|
||||
|
||||
// probeHardware is the registry-independent core of ProbeActiveHardware, so it
|
||||
// can be unit-tested without touching the global signer.
|
||||
func probeHardware(ctx context.Context, s Signer) (HardwareInfo, bool, error) {
|
||||
p, ok := s.(HardwareProber)
|
||||
if !ok {
|
||||
return HardwareInfo{}, false, nil
|
||||
}
|
||||
info, err := p.ProbeHardware(ctx)
|
||||
return info, true, err
|
||||
}
|
||||
|
||||
// cleanProbeError renders err's message with redundant re-wraps collapsed. Some
|
||||
// backends (e.g. facebookincubator/sks) wrap an error twice with the SAME "%w"
|
||||
// prefix, yielding "P: P: cause"; this peels each outer layer whose only
|
||||
// contribution is to repeat the prefix already present in the wrapped error,
|
||||
// leaving a single "P: cause". A layer that adds genuinely new context is kept.
|
||||
func cleanProbeError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := err.Error()
|
||||
for {
|
||||
inner := errors.Unwrap(err)
|
||||
if inner == nil {
|
||||
break
|
||||
}
|
||||
innerMsg := inner.Error()
|
||||
prefix, ok := strings.CutSuffix(msg, innerMsg)
|
||||
if !ok || prefix == "" || !strings.HasPrefix(innerMsg, prefix) {
|
||||
break
|
||||
}
|
||||
msg, err = innerMsg, inner
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// AlgForKey returns the JWS alg for a public key: EC P-256 -> ES256, RSA -> RS256.
|
||||
// The signer backend chooses the key type (the macOS keychain signer uses an
|
||||
// RSA-2048 key, hence RS256).
|
||||
func AlgForKey(pub crypto.PublicKey) (string, error) {
|
||||
switch k := pub.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
if k.Curve == elliptic.P256() {
|
||||
return AlgES256, nil
|
||||
}
|
||||
return "", fmt.Errorf("keysigner: unsupported EC curve %q (only P-256/ES256)", k.Curve.Params().Name)
|
||||
case *rsa.PublicKey:
|
||||
return AlgRS256, nil
|
||||
default:
|
||||
return "", fmt.Errorf("keysigner: unsupported public key type %T", pub)
|
||||
}
|
||||
}
|
||||
|
||||
// ecdsaDERToJOSE converts an ASN.1 DER-encoded ECDSA signature — the form most
|
||||
// TEE/TPM backends emit (e.g. facebookincubator/sks marshals the TPM's r,s with
|
||||
// asn1.Marshal) — into the fixed-width r||s form JWS requires for ES256
|
||||
// (RFC 7518 §3.4). byteLen is the curve coordinate size (32 for P-256), so the
|
||||
// result is exactly 2*byteLen bytes with r and s each left-zero-padded.
|
||||
//
|
||||
// This is intentionally part of the pure-stdlib core (not a platform signer) so
|
||||
// it can be unit-tested with a software key on any machine, including TPM-less CI.
|
||||
func ecdsaDERToJOSE(der []byte, byteLen int) ([]byte, error) {
|
||||
var sig struct{ R, S *big.Int }
|
||||
rest, err := asn1.Unmarshal(der, &sig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keysigner: parse ECDSA DER signature: %w", err)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
return nil, fmt.Errorf("keysigner: %d trailing byte(s) after ECDSA DER signature", len(rest))
|
||||
}
|
||||
if sig.R == nil || sig.S == nil || sig.R.Sign() <= 0 || sig.S.Sign() <= 0 {
|
||||
return nil, fmt.Errorf("keysigner: ECDSA signature has non-positive r/s")
|
||||
}
|
||||
// Guard before FillBytes, which panics if the scalar does not fit in byteLen.
|
||||
if sig.R.BitLen() > byteLen*8 || sig.S.BitLen() > byteLen*8 {
|
||||
return nil, fmt.Errorf("keysigner: ECDSA r/s exceeds %d-byte coordinate", byteLen)
|
||||
}
|
||||
out := make([]byte, 2*byteLen)
|
||||
sig.R.FillBytes(out[:byteLen])
|
||||
sig.S.FillBytes(out[byteLen:])
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EncodePublicKey marshals pub to PKIX DER and base64-encodes it (std encoding),
|
||||
// matching the public-key form the registration backend binds to the app.
|
||||
func EncodePublicKey(pub crypto.PublicKey) (string, error) {
|
||||
der, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("keysigner: encode public key: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(der), nil
|
||||
}
|
||||
|
||||
// PublicKeyJWK returns the RFC 7517 JSON Web Key for pub, used to embed the
|
||||
// public key in the attestation JWT's "jwk" header so the registration backend
|
||||
// can bind it to the app. EC keys use base64url fixed-width coordinates
|
||||
// (RFC 7518 §6.2.1); RSA keys use base64url-encoded modulus and exponent.
|
||||
func PublicKeyJWK(pub crypto.PublicKey) (map[string]any, error) {
|
||||
switch k := pub.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
if k.Curve != elliptic.P256() {
|
||||
return nil, fmt.Errorf("keysigner: JWK supports EC P-256 only, got %q", k.Curve.Params().Name)
|
||||
}
|
||||
const coordLen = 32 // P-256 field element size
|
||||
x := make([]byte, coordLen)
|
||||
y := make([]byte, coordLen)
|
||||
k.X.FillBytes(x)
|
||||
k.Y.FillBytes(y)
|
||||
return map[string]any{
|
||||
"use": "sig",
|
||||
"kty": "EC",
|
||||
"crv": "P-256",
|
||||
"x": base64.RawURLEncoding.EncodeToString(x),
|
||||
"y": base64.RawURLEncoding.EncodeToString(y),
|
||||
}, nil
|
||||
case *rsa.PublicKey:
|
||||
return map[string]any{
|
||||
"use": "sig",
|
||||
"kty": "RSA",
|
||||
"n": base64.RawURLEncoding.EncodeToString(k.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()),
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("keysigner: unsupported public key type %T for JWK", pub)
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package keysigner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlgForKey(t *testing.T) {
|
||||
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alg, err := AlgForKey(ec.Public()); err != nil || alg != AlgES256 {
|
||||
t.Errorf("P-256: alg=%q err=%v, want ES256/nil", alg, err)
|
||||
}
|
||||
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alg, err := AlgForKey(rsaKey.Public()); err != nil || alg != AlgRS256 {
|
||||
t.Errorf("RSA: alg=%q err=%v, want RS256/nil", alg, err)
|
||||
}
|
||||
|
||||
ec384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := AlgForKey(ec384.Public()); err == nil {
|
||||
t.Error("P-384: expected unsupported-curve error")
|
||||
}
|
||||
|
||||
if _, err := AlgForKey("not a key"); err == nil {
|
||||
t.Error("string: expected unsupported-type error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodePublicKeyRoundTrip(t *testing.T) {
|
||||
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enc, err := EncodePublicKey(ec.Public())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
der, err := base64.StdEncoding.DecodeString(enc)
|
||||
if err != nil {
|
||||
t.Fatalf("not valid base64: %v", err)
|
||||
}
|
||||
pub, err := x509.ParsePKIXPublicKey(der)
|
||||
if err != nil {
|
||||
t.Fatalf("not valid PKIX: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(pub, ec.Public()) {
|
||||
t.Error("public key did not round-trip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyJWK_EC(t *testing.T) {
|
||||
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwk, err := PublicKeyJWK(ec.Public())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if jwk["kty"] != "EC" || jwk["crv"] != "P-256" {
|
||||
t.Errorf("jwk = %v, want kty=EC crv=P-256", jwk)
|
||||
}
|
||||
if jwk["use"] != "sig" {
|
||||
t.Errorf("jwk use = %v, want sig", jwk["use"])
|
||||
}
|
||||
x, _ := jwk["x"].(string)
|
||||
xb, err := base64.RawURLEncoding.DecodeString(x)
|
||||
if err != nil || len(xb) != 32 {
|
||||
t.Errorf("x = %q (decoded %d bytes), want 32-byte base64url", x, len(xb))
|
||||
}
|
||||
if _, ok := jwk["y"].(string); !ok {
|
||||
t.Error("jwk missing y")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyJWK_RSA(t *testing.T) {
|
||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwk, err := PublicKeyJWK(rsaKey.Public())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if jwk["kty"] != "RSA" || jwk["n"] == "" || jwk["e"] == "" {
|
||||
t.Errorf("jwk = %v, want kty=RSA with n,e", jwk)
|
||||
}
|
||||
if jwk["use"] != "sig" {
|
||||
t.Errorf("jwk use = %v, want sig", jwk["use"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyJWK_UnsupportedCurve(t *testing.T) {
|
||||
ec384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := PublicKeyJWK(ec384.Public()); err == nil {
|
||||
t.Error("P-384: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestECDSADERToJOSE(t *testing.T) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Iterate so we hit signatures whose r or s has its high bit set (ASN.1 pads
|
||||
// those with a leading 0x00) and whose scalars are short (need left-zero
|
||||
// padding) — verifying fixed-width conversion in both directions.
|
||||
for i := 0; i < 64; i++ {
|
||||
digest := sha256.Sum256([]byte{byte(i), byte(i >> 8), 'j', 'w', 't'})
|
||||
der, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jose, err := ecdsaDERToJOSE(der, 32)
|
||||
if err != nil {
|
||||
t.Fatalf("iter %d: %v", i, err)
|
||||
}
|
||||
if len(jose) != 64 {
|
||||
t.Fatalf("iter %d: len(jose)=%d, want 64 (fixed-width r||s)", i, len(jose))
|
||||
}
|
||||
r := new(big.Int).SetBytes(jose[:32])
|
||||
s := new(big.Int).SetBytes(jose[32:])
|
||||
if !ecdsa.Verify(&key.PublicKey, digest[:], r, s) {
|
||||
t.Fatalf("iter %d: converted r||s did not verify against the public key", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestECDSADERToJOSE_Errors(t *testing.T) {
|
||||
if _, err := ecdsaDERToJOSE([]byte{0x01, 0x02, 0x03}, 32); err == nil {
|
||||
t.Error("garbage DER: expected error")
|
||||
}
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256([]byte("trailing"))
|
||||
der, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ecdsaDERToJOSE(append(der, 0x00), 32); err == nil {
|
||||
t.Error("DER with trailing byte: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
type stubSigner struct{}
|
||||
|
||||
func (stubSigner) EnsureKey(context.Context, KeyRef) (crypto.PublicKey, error) { return nil, nil }
|
||||
func (stubSigner) PublicKey(context.Context, KeyRef) (crypto.PublicKey, error) { return nil, nil }
|
||||
func (stubSigner) Sign(context.Context, KeyRef, []byte) ([]byte, string, error) { return nil, "", nil }
|
||||
|
||||
func TestCleanProbeError(t *testing.T) {
|
||||
cause := errors.New("open /dev/tpmrm0: permission denied")
|
||||
const p = "sks: error fetching Secure Hardware Vendor Data: "
|
||||
|
||||
// sks double-wraps with the same %w prefix → collapse to a single prefix.
|
||||
doubled := fmt.Errorf(p+"%w", fmt.Errorf(p+"%w", cause))
|
||||
if got, want := cleanProbeError(doubled), p+cause.Error(); got != want {
|
||||
t.Errorf("doubled: got %q, want %q", got, want)
|
||||
}
|
||||
// Triple wrap collapses too.
|
||||
if got, want := cleanProbeError(fmt.Errorf(p+"%w", doubled)), p+cause.Error(); got != want {
|
||||
t.Errorf("tripled: got %q, want %q", got, want)
|
||||
}
|
||||
// A layer adding genuinely new context is preserved.
|
||||
if got, want := cleanProbeError(fmt.Errorf("load: %w", cause)), "load: "+cause.Error(); got != want {
|
||||
t.Errorf("distinct prefix: got %q, want %q", got, want)
|
||||
}
|
||||
// nil and unwrapped-leaf cases.
|
||||
if got := cleanProbeError(nil); got != "" {
|
||||
t.Errorf("nil: got %q, want empty", got)
|
||||
}
|
||||
if got := cleanProbeError(cause); got != cause.Error() {
|
||||
t.Errorf("leaf: got %q, want %q", got, cause.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type proberSigner struct {
|
||||
stubSigner
|
||||
info HardwareInfo
|
||||
}
|
||||
|
||||
func (p proberSigner) ProbeHardware(context.Context) (HardwareInfo, error) { return p.info, nil }
|
||||
|
||||
func TestProbeHardware(t *testing.T) {
|
||||
// nil signer and a signer that does not implement HardwareProber both yield ok=false.
|
||||
if _, ok, _ := probeHardware(context.Background(), nil); ok {
|
||||
t.Error("nil signer: ok should be false")
|
||||
}
|
||||
if _, ok, _ := probeHardware(context.Background(), stubSigner{}); ok {
|
||||
t.Error("non-prober signer: ok should be false")
|
||||
}
|
||||
|
||||
want := HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"}
|
||||
info, ok, err := probeHardware(context.Background(), proberSigner{info: want})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("prober: ok=%v err=%v, want true/nil", ok, err)
|
||||
}
|
||||
if info != want {
|
||||
t.Errorf("info = %+v, want %+v", info, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
if Active() != nil {
|
||||
t.Skip("a signer is already registered in this build")
|
||||
}
|
||||
Register(stubSigner{})
|
||||
if _, ok := Active().(stubSigner); !ok {
|
||||
t.Error("Active did not return the registered signer")
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package keysigner
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
active Signer
|
||||
)
|
||||
|
||||
// Register sets the active Signer. It is typically called from the init() of a
|
||||
// build-tagged or extension package that provides the platform TEE/Keychain
|
||||
// implementation. The last registration wins (one backend per platform).
|
||||
func Register(s Signer) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
active = s
|
||||
}
|
||||
|
||||
// Active returns the registered Signer, or nil if none is available — in which
|
||||
// case private_key_jwt is unsupported on this build and only client_secret auth
|
||||
// can be used.
|
||||
func Active() Signer {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return active
|
||||
}
|
||||
@@ -1,613 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// macOS non-exportable Keychain signer (compiled into every darwin build).
|
||||
//
|
||||
// It does NOT use the Secure Enclave / hardware TEE (which would require
|
||||
// code-signing entitlements that are unfriendly to open source). Instead it
|
||||
// generates an RSA-2048 key in software, imports it into a dedicated app
|
||||
// keychain as NON-EXTRACTABLE (`security import -x`), then deletes the software
|
||||
// copy — so the private key can sign but can never be exported. Signing is
|
||||
// RSASSA-PKCS1v15-SHA256 (RS256).
|
||||
//
|
||||
// Unlike the original revision, this implementation calls the Security and
|
||||
// CoreFoundation frameworks via RUNTIME FFI (github.com/ebitengine/purego)
|
||||
// instead of cgo. The security model is identical (the private key is still a
|
||||
// non-extractable keychain key and every signature is produced by the OS via
|
||||
// SecKeyCreateSignature), but the binary builds with CGO_ENABLED=0 and can be
|
||||
// cross-compiled for darwin from any host — so release binaries no longer
|
||||
// require a native macOS build runner.
|
||||
//
|
||||
// Build with: go build (cgo-free; compiled into every darwin build, no tag)
|
||||
package keysigner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// ---- Security / CoreFoundation runtime bindings (purego, no cgo) ----
|
||||
|
||||
const (
|
||||
cfFrameworkPath = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
|
||||
secFrameworkPath = "/System/Library/Frameworks/Security.framework/Security"
|
||||
|
||||
// kCFStringEncodingUTF8 (CFStringBuiltInEncodings).
|
||||
cfStringEncodingUTF8 = 0x08000100
|
||||
|
||||
// OSStatus values.
|
||||
errSecSuccess = 0
|
||||
)
|
||||
|
||||
var (
|
||||
ffiOnce sync.Once
|
||||
ffiErr error
|
||||
|
||||
cfDataCreate func(alloc uintptr, bytes *byte, length int) uintptr
|
||||
cfDataGetLength func(d uintptr) int
|
||||
cfDataGetBytePtr func(d uintptr) unsafe.Pointer
|
||||
cfStringCreate func(alloc uintptr, cstr *byte, encoding uint32) uintptr
|
||||
cfArrayCreate func(alloc uintptr, values *uintptr, numValues int, cb uintptr) uintptr
|
||||
cfDictCreateMutable func(alloc uintptr, capacity int, keyCB, valCB uintptr) uintptr
|
||||
cfDictSetValue func(dict, key, val uintptr)
|
||||
cfRelease func(ref uintptr)
|
||||
cfErrorGetCode func(e uintptr) int
|
||||
secKeychainOpen func(path *byte, out *uintptr) int32
|
||||
secItemCopyMatching func(query uintptr, result *uintptr) int32
|
||||
secItemUpdate func(query, attrs uintptr) int32
|
||||
secKeyCreateSignature func(key, algo, data uintptr, errOut *uintptr) uintptr
|
||||
|
||||
// CFTypeRef data-symbol constants (deref to obtain the held ref value).
|
||||
kSecClass uintptr
|
||||
kSecClassKey uintptr
|
||||
kSecAttrKeyClass uintptr
|
||||
kSecAttrKeyClassPrivate uintptr
|
||||
kSecAttrKeyType uintptr
|
||||
kSecAttrKeyTypeRSA uintptr
|
||||
kSecAttrApplicationLabel uintptr
|
||||
kSecReturnRef uintptr
|
||||
kSecMatchSearchList uintptr
|
||||
kSecAttrLabel uintptr
|
||||
kCFBooleanTrue uintptr
|
||||
algRSAPKCS1SHA256 uintptr
|
||||
|
||||
// Struct-symbol constants (passed BY ADDRESS, not dereferenced).
|
||||
cbTypeArray uintptr
|
||||
cbDictKey uintptr
|
||||
cbDictValue uintptr
|
||||
)
|
||||
|
||||
// loadFFI resolves the framework functions and constants once. Any failure
|
||||
// (framework missing, symbol absent) is returned to every caller so signing
|
||||
// fails cleanly rather than crashing.
|
||||
func loadFFI() error {
|
||||
ffiOnce.Do(func() {
|
||||
cf, err := purego.Dlopen(cfFrameworkPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
ffiErr = fmt.Errorf("keysigner: dlopen CoreFoundation: %w", err)
|
||||
return
|
||||
}
|
||||
sec, err := purego.Dlopen(secFrameworkPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
ffiErr = fmt.Errorf("keysigner: dlopen Security: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
purego.RegisterLibFunc(&cfDataCreate, cf, "CFDataCreate")
|
||||
purego.RegisterLibFunc(&cfDataGetLength, cf, "CFDataGetLength")
|
||||
purego.RegisterLibFunc(&cfDataGetBytePtr, cf, "CFDataGetBytePtr")
|
||||
purego.RegisterLibFunc(&cfStringCreate, cf, "CFStringCreateWithCString")
|
||||
purego.RegisterLibFunc(&cfArrayCreate, cf, "CFArrayCreate")
|
||||
purego.RegisterLibFunc(&cfDictCreateMutable, cf, "CFDictionaryCreateMutable")
|
||||
purego.RegisterLibFunc(&cfDictSetValue, cf, "CFDictionarySetValue")
|
||||
purego.RegisterLibFunc(&cfRelease, cf, "CFRelease")
|
||||
purego.RegisterLibFunc(&cfErrorGetCode, cf, "CFErrorGetCode")
|
||||
purego.RegisterLibFunc(&secKeychainOpen, sec, "SecKeychainOpen")
|
||||
purego.RegisterLibFunc(&secItemCopyMatching, sec, "SecItemCopyMatching")
|
||||
purego.RegisterLibFunc(&secItemUpdate, sec, "SecItemUpdate")
|
||||
purego.RegisterLibFunc(&secKeyCreateSignature, sec, "SecKeyCreateSignature")
|
||||
|
||||
// CFStringRef/CFBooleanRef constants: Dlsym gives the address of the
|
||||
// exported variable; deref once to read the ref it holds.
|
||||
derefs := []struct {
|
||||
dst *uintptr
|
||||
handle uintptr
|
||||
name string
|
||||
}{
|
||||
{&kSecClass, sec, "kSecClass"},
|
||||
{&kSecClassKey, sec, "kSecClassKey"},
|
||||
{&kSecAttrKeyClass, sec, "kSecAttrKeyClass"},
|
||||
{&kSecAttrKeyClassPrivate, sec, "kSecAttrKeyClassPrivate"},
|
||||
{&kSecAttrKeyType, sec, "kSecAttrKeyType"},
|
||||
{&kSecAttrKeyTypeRSA, sec, "kSecAttrKeyTypeRSA"},
|
||||
{&kSecAttrApplicationLabel, sec, "kSecAttrApplicationLabel"},
|
||||
{&kSecReturnRef, sec, "kSecReturnRef"},
|
||||
{&kSecMatchSearchList, sec, "kSecMatchSearchList"},
|
||||
{&kSecAttrLabel, sec, "kSecAttrLabel"},
|
||||
{&kCFBooleanTrue, cf, "kCFBooleanTrue"},
|
||||
{&algRSAPKCS1SHA256, sec, "kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA256"},
|
||||
}
|
||||
for _, d := range derefs {
|
||||
sym, e := purego.Dlsym(d.handle, d.name)
|
||||
if e != nil || sym == 0 {
|
||||
ffiErr = fmt.Errorf("keysigner: dlsym %s: %v", d.name, e)
|
||||
return
|
||||
}
|
||||
// deref of a stable dylib data-symbol address (not Go-managed memory), so safe.
|
||||
*d.dst = *(*uintptr)(unsafe.Pointer(sym)) //nolint:govet // unsafeptr: see comment above
|
||||
}
|
||||
|
||||
// Callback structs are passed by address (no deref).
|
||||
addrs := []struct {
|
||||
dst *uintptr
|
||||
handle uintptr
|
||||
name string
|
||||
}{
|
||||
{&cbTypeArray, cf, "kCFTypeArrayCallBacks"},
|
||||
{&cbDictKey, cf, "kCFTypeDictionaryKeyCallBacks"},
|
||||
{&cbDictValue, cf, "kCFTypeDictionaryValueCallBacks"},
|
||||
}
|
||||
for _, a := range addrs {
|
||||
sym, e := purego.Dlsym(a.handle, a.name)
|
||||
if e != nil || sym == 0 {
|
||||
ffiErr = fmt.Errorf("keysigner: dlsym %s: %v", a.name, e)
|
||||
return
|
||||
}
|
||||
*a.dst = sym
|
||||
}
|
||||
})
|
||||
return ffiErr
|
||||
}
|
||||
|
||||
// cstr returns a pointer to a NUL-terminated copy of s. The backing array stays
|
||||
// alive while the returned pointer is reachable.
|
||||
func cstr(s string) *byte {
|
||||
b := append([]byte(s), 0)
|
||||
return &b[0]
|
||||
}
|
||||
|
||||
// cfBytes wraps Go bytes in a CFData (CFDataCreate copies the bytes). Caller
|
||||
// releases the returned CFDataRef.
|
||||
func cfBytes(b []byte) uintptr {
|
||||
var p *byte
|
||||
if len(b) > 0 {
|
||||
p = &b[0]
|
||||
}
|
||||
d := cfDataCreate(0, p, len(b))
|
||||
runtime.KeepAlive(b)
|
||||
return d
|
||||
}
|
||||
|
||||
// keychainSearchArray opens the dedicated keychain file and wraps it in a
|
||||
// CFArray for kSecMatchSearchList. Caller releases the returned array.
|
||||
//
|
||||
// NOTE: SecKeychainOpen / the file-based keychain are deprecated by Apple in
|
||||
// favor of the data-protection keychain. They still function on current macOS;
|
||||
// migrating off them is tracked separately and is independent of the cgo→purego
|
||||
// change (the original cgo version used the same APIs).
|
||||
func keychainSearchArray(keychainPath string) (uintptr, error) {
|
||||
var kc uintptr
|
||||
if st := secKeychainOpen(cstr(keychainPath), &kc); st != errSecSuccess {
|
||||
return 0, keychainError("open keychain", int(st))
|
||||
}
|
||||
vals := [1]uintptr{kc}
|
||||
arr := cfArrayCreate(0, &vals[0], 1, cbTypeArray)
|
||||
cfRelease(kc) // the array retains it
|
||||
if arr == 0 {
|
||||
return 0, fmt.Errorf("keysigner: CFArrayCreate(search list) failed")
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
// findPrivateKey locates the non-extractable private key by its application
|
||||
// label within the dedicated keychain. Caller releases the returned SecKeyRef.
|
||||
func findPrivateKey(appLabel []byte, keychainPath string) (uintptr, error) {
|
||||
search, err := keychainSearchArray(keychainPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer cfRelease(search)
|
||||
|
||||
labelData := cfBytes(appLabel)
|
||||
defer cfRelease(labelData)
|
||||
|
||||
q := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
|
||||
if q == 0 {
|
||||
return 0, fmt.Errorf("keysigner: CFDictionaryCreateMutable(query) failed")
|
||||
}
|
||||
defer cfRelease(q)
|
||||
cfDictSetValue(q, kSecClass, kSecClassKey)
|
||||
cfDictSetValue(q, kSecAttrKeyClass, kSecAttrKeyClassPrivate)
|
||||
cfDictSetValue(q, kSecAttrKeyType, kSecAttrKeyTypeRSA)
|
||||
cfDictSetValue(q, kSecAttrApplicationLabel, labelData)
|
||||
cfDictSetValue(q, kSecReturnRef, kCFBooleanTrue)
|
||||
cfDictSetValue(q, kSecMatchSearchList, search)
|
||||
|
||||
var keyRef uintptr
|
||||
if st := secItemCopyMatching(q, &keyRef); st != errSecSuccess {
|
||||
return 0, keychainError("find private key", int(st))
|
||||
}
|
||||
return keyRef, nil
|
||||
}
|
||||
|
||||
// securityBin is invoked by absolute path so a poisoned PATH cannot hijack it.
|
||||
const securityBin = "/usr/bin/security"
|
||||
|
||||
// keychainSigner implements Signer using a macOS non-exportable Keychain key.
|
||||
type keychainSigner struct{}
|
||||
|
||||
func init() { Register(keychainSigner{}) }
|
||||
|
||||
// ProbeHardware reports the macOS Keychain backend backing this signer. The
|
||||
// keychain signer is compiled into every darwin build and needs no special
|
||||
// hardware, so it reports available whenever the Security tooling is present.
|
||||
// It performs no key access, so it never prompts. Implementing HardwareProber
|
||||
// is what lets `doctor` report the signer as present rather than treating the
|
||||
// (prober-less) signer as "no TEE signer in this build".
|
||||
func (keychainSigner) ProbeHardware(_ context.Context) (HardwareInfo, error) {
|
||||
info := HardwareInfo{Backend: "keychain", VendorName: "macOS Keychain"}
|
||||
// A missing security tool is a status (Available=false via Reason), not a
|
||||
// probe error — so we deliberately return a nil error here.
|
||||
if _, err := vfs.Stat(securityBin); err != nil {
|
||||
info.Reason = securityBin + " not found"
|
||||
return info, nil //nolint:nilerr // absence is reported via Reason, not as an error
|
||||
}
|
||||
info.Available = true
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (keychainSigner) EnsureKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
|
||||
if md, err := readKeyMetadata(ref.Label); err == nil {
|
||||
return decodePublicKey(md.PublicKey)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
return createKeychainKey(ref.Label)
|
||||
}
|
||||
|
||||
func (keychainSigner) PublicKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
|
||||
md, err := readKeyMetadata(ref.Label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodePublicKey(md.PublicKey)
|
||||
}
|
||||
|
||||
func (keychainSigner) Sign(_ context.Context, ref KeyRef, signingInput []byte) ([]byte, string, error) {
|
||||
if err := loadFFI(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
md, err := readKeyMetadata(ref.Label)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
appLabel, err := hex.DecodeString(md.AppLabel)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("keysigner: decode app label: %w", err)
|
||||
}
|
||||
if len(appLabel) == 0 {
|
||||
// Guard the &appLabel[0] pointer below against corrupted metadata.
|
||||
return nil, "", fmt.Errorf("keysigner: key metadata for %q has empty app label", ref.Label)
|
||||
}
|
||||
keychain, err := ensureKeychain()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
keyRef, err := findPrivateKey(appLabel, keychain)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer cfRelease(keyRef)
|
||||
|
||||
digest := sha256.Sum256(signingInput)
|
||||
digestData := cfBytes(digest[:])
|
||||
defer cfRelease(digestData)
|
||||
|
||||
var errRef uintptr
|
||||
sigRef := secKeyCreateSignature(keyRef, algRSAPKCS1SHA256, digestData, &errRef)
|
||||
if sigRef == 0 {
|
||||
code := 0
|
||||
if errRef != 0 {
|
||||
code = cfErrorGetCode(errRef)
|
||||
cfRelease(errRef)
|
||||
}
|
||||
return nil, "", fmt.Errorf("keysigner: SecKeyCreateSignature failed (CFError %d)", code)
|
||||
}
|
||||
defer cfRelease(sigRef)
|
||||
|
||||
n := cfDataGetLength(sigRef)
|
||||
bp := cfDataGetBytePtr(sigRef)
|
||||
out := make([]byte, n)
|
||||
copy(out, unsafe.Slice((*byte)(bp), n))
|
||||
// RS256: the SecKey PKCS1v15-SHA256 signature is the JOSE signature as-is.
|
||||
return out, AlgRS256, nil
|
||||
}
|
||||
|
||||
// keyMetadata records the public key + the keychain application-label used to
|
||||
// locate the non-extractable private key.
|
||||
type keyMetadata struct {
|
||||
PublicKey string `json:"public_key"` // PKIX DER, std base64 (see EncodePublicKey)
|
||||
AppLabel string `json:"app_label"` // hex(sha1(PKCS1 public key))
|
||||
}
|
||||
|
||||
func createKeychainKey(label string) (crypto.PublicKey, error) {
|
||||
metadataPath, err := keyMetadataPath(label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keysigner: generate RSA key: %w", err)
|
||||
}
|
||||
appLabel := sha1.Sum(x509.MarshalPKCS1PublicKey(&privateKey.PublicKey))
|
||||
|
||||
pemFile, err := vfs.CreateTemp("", "lark-keysigner-*.pem")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keysigner: temp key file: %w", err)
|
||||
}
|
||||
pemPath := pemFile.Name()
|
||||
defer vfs.Remove(pemPath)
|
||||
if err := pemFile.Chmod(0600); err != nil {
|
||||
pemFile.Close()
|
||||
return nil, err
|
||||
}
|
||||
der := x509.MarshalPKCS1PrivateKey(privateKey)
|
||||
if _, err := pemFile.WriteString("-----BEGIN RSA PRIVATE KEY-----\n" +
|
||||
base64Wrap(der) + "-----END RSA PRIVATE KEY-----\n"); err != nil {
|
||||
pemFile.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := pemFile.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
executable, err := vfs.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keysigner: resolve executable: %w", err)
|
||||
}
|
||||
keychain, err := ensureKeychain()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// -x: import as NON-EXTRACTABLE; the software copy (pemPath) is then removed.
|
||||
importCmd := exec.Command(securityBin, "import", pemPath, "-k", keychain, "-t", "priv", "-f", "openssl", "-x", "-A", "-T", executable)
|
||||
if out, err := importCmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("keysigner: import non-extractable key: %w: %s", err, summarizeCmdOutput(out))
|
||||
}
|
||||
if err := setKeychainKeyLabel(appLabel[:], keychain, label); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encodedPub, err := EncodePublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writeKeyMetadata(metadataPath, keyMetadata{PublicKey: encodedPub, AppLabel: hex.EncodeToString(appLabel[:])}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &privateKey.PublicKey, nil
|
||||
}
|
||||
|
||||
func setKeychainKeyLabel(appLabel []byte, keychain, label string) error {
|
||||
if err := loadFFI(); err != nil {
|
||||
return err
|
||||
}
|
||||
search, err := keychainSearchArray(keychain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cfRelease(search)
|
||||
|
||||
labelData := cfBytes(appLabel)
|
||||
defer cfRelease(labelData)
|
||||
|
||||
q := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
|
||||
if q == 0 {
|
||||
return fmt.Errorf("keysigner: CFDictionaryCreateMutable(query) failed")
|
||||
}
|
||||
defer cfRelease(q)
|
||||
cfDictSetValue(q, kSecClass, kSecClassKey)
|
||||
cfDictSetValue(q, kSecAttrKeyClass, kSecAttrKeyClassPrivate)
|
||||
cfDictSetValue(q, kSecAttrKeyType, kSecAttrKeyTypeRSA)
|
||||
cfDictSetValue(q, kSecAttrApplicationLabel, labelData)
|
||||
cfDictSetValue(q, kSecMatchSearchList, search)
|
||||
|
||||
cfLabel := cfStringCreate(0, cstr(label), cfStringEncodingUTF8)
|
||||
if cfLabel == 0 {
|
||||
return fmt.Errorf("keysigner: CFStringCreateWithCString failed")
|
||||
}
|
||||
defer cfRelease(cfLabel)
|
||||
attrs := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
|
||||
if attrs == 0 {
|
||||
return fmt.Errorf("keysigner: CFDictionaryCreateMutable(attrs) failed")
|
||||
}
|
||||
defer cfRelease(attrs)
|
||||
cfDictSetValue(attrs, kSecAttrLabel, cfLabel)
|
||||
|
||||
if st := secItemUpdate(q, attrs); st != errSecSuccess {
|
||||
return keychainError("set keychain key label", int(st))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodePublicKey(encoded string) (crypto.PublicKey, error) {
|
||||
der, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keysigner: decode public key: %w", err)
|
||||
}
|
||||
return x509.ParsePKIXPublicKey(der)
|
||||
}
|
||||
|
||||
// base64Wrap PEM-wraps DER bytes at 64 columns.
|
||||
func base64Wrap(der []byte) string {
|
||||
enc := base64.StdEncoding.EncodeToString(der)
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(enc); i += 64 {
|
||||
end := i + 64
|
||||
if end > len(enc) {
|
||||
end = len(enc)
|
||||
}
|
||||
b.WriteString(enc[i:end])
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func readKeyMetadata(label string) (*keyMetadata, error) {
|
||||
path, err := keyMetadataPath(label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err // preserves os.ErrNotExist for EnsureKey
|
||||
}
|
||||
var md keyMetadata
|
||||
if err := json.Unmarshal(data, &md); err != nil {
|
||||
return nil, fmt.Errorf("keysigner: parse key metadata: %w", err)
|
||||
}
|
||||
return &md, nil
|
||||
}
|
||||
|
||||
func writeKeyMetadata(path string, md keyMetadata) error {
|
||||
if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(md, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return vfs.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func ensureKeychain() (string, error) {
|
||||
keychainPath, err := keychainFilePath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
password, err := keychainPassword()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := vfs.Stat(keychainPath); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("keysigner: stat keychain: %w", err)
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(keychainPath), 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, args := range [][]string{
|
||||
{"create-keychain", "-p", password, keychainPath},
|
||||
{"set-keychain-settings", keychainPath},
|
||||
{"unlock-keychain", "-p", password, keychainPath},
|
||||
} {
|
||||
if out, err := exec.Command(securityBin, args...).CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("keysigner: security %s: %w: %s", args[0], err, summarizeCmdOutput(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
return keychainPath, nil
|
||||
}
|
||||
|
||||
func keysignerDir() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("keysigner: resolve config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(configDir, "lark-cli", "keysigner"), nil
|
||||
}
|
||||
|
||||
func keychainFilePath() (string, error) {
|
||||
dir, err := keysignerDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "lark-cli.keychain"), nil
|
||||
}
|
||||
|
||||
func keychainPassword() (string, error) {
|
||||
dir, err := keysignerDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(dir, "keychain.pass")
|
||||
if data, err := vfs.ReadFile(path); err == nil {
|
||||
if pw := strings.TrimSpace(string(data)); pw != "" {
|
||||
return pw, nil
|
||||
}
|
||||
return "", fmt.Errorf("keysigner: empty keychain password")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
pw := hex.EncodeToString(buf)
|
||||
if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := vfs.WriteFile(path, []byte(pw+"\n"), 0600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pw, nil
|
||||
}
|
||||
|
||||
func keyMetadataPath(label string) (string, error) {
|
||||
dir, err := keysignerDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := sha256.Sum256([]byte(label))
|
||||
return filepath.Join(dir, "keys", hex.EncodeToString(id[:])+".json"), nil
|
||||
}
|
||||
|
||||
// summarizeCmdOutput bounds external command output before it is embedded in
|
||||
// an error: first line only, capped at 200 chars.
|
||||
func summarizeCmdOutput(out []byte) string {
|
||||
s := strings.TrimSpace(string(out))
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = strings.TrimSpace(s[:i])
|
||||
}
|
||||
const maxLen = 200
|
||||
if len(s) > maxLen {
|
||||
s = s[:maxLen] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func keychainError(operation string, status int) error {
|
||||
switch status {
|
||||
case -25299:
|
||||
return fmt.Errorf("keysigner: %s: key already exists", operation)
|
||||
case -25300:
|
||||
return fmt.Errorf("keysigner: %s: key not found", operation)
|
||||
case -2:
|
||||
return fmt.Errorf("keysigner: %s: allocation failed", operation)
|
||||
default:
|
||||
return fmt.Errorf("keysigner: %s: Security framework status %d", operation, status)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package keysigner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestKeychainSignerRegistered confirms the keychain_signer build self-registers
|
||||
// (init → Register), so keysigner.Active() is non-nil. No keychain access.
|
||||
func TestKeychainSignerRegistered(t *testing.T) {
|
||||
if _, ok := Active().(keychainSigner); !ok {
|
||||
t.Fatalf("Active() = %T, want keychainSigner (keychain_signer build must self-register)", Active())
|
||||
}
|
||||
}
|
||||
|
||||
// TestKeychainSignerRoundTrip creates a real non-extractable RSA key, signs, and
|
||||
// verifies RS256 against the returned public key. Gated by LARK_KEYCHAIN_IT
|
||||
// because it mutates the dedicated lark-cli keychain store. The signer is now
|
||||
// cgo-free (purego runtime FFI), so it runs with CGO_ENABLED=0. Run with:
|
||||
//
|
||||
// LARK_KEYCHAIN_IT=1 go test -run RoundTrip ./internal/keysigner/
|
||||
func TestKeychainSignerRoundTrip(t *testing.T) {
|
||||
if os.Getenv("LARK_KEYCHAIN_IT") == "" {
|
||||
t.Skip("set LARK_KEYCHAIN_IT=1 to run (mutates the macOS keychain)")
|
||||
}
|
||||
s := keychainSigner{}
|
||||
ref := KeyRef{Label: "lark-cli-keychain-it"}
|
||||
|
||||
pub, err := s.EnsureKey(context.Background(), ref)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKey: %v", err)
|
||||
}
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
t.Fatalf("public key = %T, want *rsa.PublicKey", pub)
|
||||
}
|
||||
if alg, err := AlgForKey(pub); err != nil || alg != AlgRS256 {
|
||||
t.Fatalf("AlgForKey = %q, %v; want RS256", alg, err)
|
||||
}
|
||||
|
||||
input := []byte("header.payload")
|
||||
sig, alg, err := s.Sign(context.Background(), ref, input)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if alg != AlgRS256 {
|
||||
t.Errorf("Sign alg = %q, want RS256", alg)
|
||||
}
|
||||
h := sha256.Sum256(input)
|
||||
if err := rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, h[:], sig); err != nil {
|
||||
t.Errorf("RS256 signature did not verify: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
//go:build linux || (windows && amd64)
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// TPM 2.0 signer (compiled into every linux and windows/amd64 build, no build
|
||||
// tag required), backed by github.com/facebookincubator/sks.
|
||||
//
|
||||
// sks holds a non-exportable ECDSA P-256 key in the platform TPM and signs
|
||||
// SHA-256 digests. On Linux it talks to /dev/tpmrm0; on Windows it uses the
|
||||
// Microsoft Platform Crypto Provider (CNG). Both backends return an ASN.1 DER
|
||||
// ECDSA signature, which we convert to the fixed-width r||s form JWS requires for
|
||||
// ES256 (see ecdsaDERToJOSE). One key is created on the first private_key_jwt
|
||||
// registration (DefaultKeyLabel) and reused for subsequent app registrations and
|
||||
// every client_assertion on the same device.
|
||||
//
|
||||
// Excluded from windows/arm64: the sks Windows dependency stack (go-ole) has no
|
||||
// arm64 VARIANT and fails to compile, so windows/arm64 falls back to
|
||||
// client_secret only (keysigner.Active() is nil). On darwin the keychain signer
|
||||
// is used instead. CGO is never required.
|
||||
package keysigner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/facebookincubator/flog"
|
||||
"github.com/facebookincubator/sks"
|
||||
)
|
||||
|
||||
// p256ByteLen is the P-256 coordinate width. sks regular keys are always ECDSA
|
||||
// P-256, so ES256 signatures are 2*p256ByteLen bytes of r||s.
|
||||
const p256ByteLen = 32
|
||||
|
||||
// keyTag is the sks key tag. Both the Linux and Windows sks backends address
|
||||
// keys by label and ignore the tag, but the macOS backend uses it, so we set a
|
||||
// stable namespaced value for forward compatibility.
|
||||
const keyTag = "com.larksuite.cli"
|
||||
|
||||
// sksSigner implements Signer (and HardwareProber) using a non-exportable
|
||||
// TPM 2.0 ECDSA key via sks.
|
||||
type sksSigner struct{}
|
||||
|
||||
func init() {
|
||||
Register(sksSigner{})
|
||||
// This sks version logs verbose TPM-operation chatter to stderr via flog (a
|
||||
// glog fork it owns exclusively) — e.g. "Loaded TPM device", "Found handle
|
||||
// for key" on every sign. The CLI does not use flog, so silence it
|
||||
// process-wide here; real failures are returned as errors, never relied upon
|
||||
// from these logs. (Newer sks switched to slog, but that lands only on its
|
||||
// go-1.24 line, which we avoid to keep the module on go 1.23.)
|
||||
flog.SetOutput(io.Discard)
|
||||
}
|
||||
|
||||
// EnsureKey returns the public key for ref, creating the TPM key if absent.
|
||||
// sks.NewKey is find-or-create: it returns the existing key when one is present.
|
||||
func (sksSigner) EnsureKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
|
||||
key, err := sks.NewKey(ref.Label, keyTag, false, true, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keysigner: ensure TPM key %q: %w", ref.Label, err)
|
||||
}
|
||||
defer key.Close()
|
||||
return ecdsaPublic(ref.Label, key.Public())
|
||||
}
|
||||
|
||||
// PublicKey returns the public key for ref without creating it. FromLabelTag does
|
||||
// not touch the TPM until Public() loads the sealed key; a missing key yields a
|
||||
// nil public key, which we surface as an error — at runtime the key MUST already
|
||||
// exist (it was bound to the app at registration), so we never silently mint a
|
||||
// new, unbound one here.
|
||||
func (sksSigner) PublicKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
|
||||
pub := sks.FromLabelTag(ref.Label).Public()
|
||||
if pub == nil {
|
||||
return nil, fmt.Errorf("keysigner: TPM key %q not found", ref.Label)
|
||||
}
|
||||
return ecdsaPublic(ref.Label, pub)
|
||||
}
|
||||
|
||||
// Sign signs signingInput with the TPM key and returns a JOSE-format ES256
|
||||
// signature (fixed-width r||s) plus its alg.
|
||||
func (sksSigner) Sign(_ context.Context, ref KeyRef, signingInput []byte) ([]byte, string, error) {
|
||||
key, err := sks.NewKey(ref.Label, keyTag, false, true, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("keysigner: load TPM key %q: %w", ref.Label, err)
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
// ES256 signs the SHA-256 digest of the JWS signing input.
|
||||
digest := sha256.Sum256(signingInput)
|
||||
der, err := key.Sign(nil, digest[:], crypto.SHA256)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("keysigner: TPM sign with key %q: %w", ref.Label, err)
|
||||
}
|
||||
// Both sks backends emit ASN.1 DER; JWS ES256 requires fixed-width r||s
|
||||
// (RFC 7518 §3.4).
|
||||
rs, err := ecdsaDERToJOSE(der, p256ByteLen)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return rs, AlgES256, nil
|
||||
}
|
||||
|
||||
// ProbeHardware reports on the TPM backing this signer without touching any key.
|
||||
// A failure to reach the TPM (no device, permission denied, not TPM 2.0) is
|
||||
// reported as Available=false with Reason set, NOT as a Go error — the probe
|
||||
// still succeeded in determining that the TEE is currently unusable.
|
||||
func (sksSigner) ProbeHardware(_ context.Context) (HardwareInfo, error) {
|
||||
info := HardwareInfo{Backend: "tpm2"}
|
||||
data, err := sks.GetSecureHardwareVendorData()
|
||||
if err != nil {
|
||||
info.Reason = cleanProbeError(err)
|
||||
return info, nil
|
||||
}
|
||||
info.VendorName = data.VendorName
|
||||
info.VendorInfo = data.VendorInfo
|
||||
info.Available = data.IsTPM20CompliantDevice
|
||||
if !info.Available {
|
||||
info.Reason = "secure hardware is not a TPM 2.0 compliant device"
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ecdsaPublic asserts that an sks public key is an ECDSA key (it always is for
|
||||
// regular sks keys) so the caller gets the concrete type AlgForKey/PublicKeyJWK expect.
|
||||
func ecdsaPublic(label string, pub crypto.PublicKey) (*ecdsa.PublicKey, error) {
|
||||
ecPub, ok := pub.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("keysigner: TPM key %q public is %T, want *ecdsa.PublicKey", label, pub)
|
||||
}
|
||||
return ecPub, nil
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
//go:build linux || (windows && amd64)
|
||||
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package keysigner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/sha256"
|
||||
"io"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/facebookincubator/flog"
|
||||
"github.com/facebookincubator/sks"
|
||||
)
|
||||
|
||||
// TestFlogSilenced verifies the mechanism init() relies on to keep sks's flog
|
||||
// TPM chatter off the CLI's stderr: SetOutput redirects flog, and io.Discard
|
||||
// drops it. Cleanup restores io.Discard so init()'s silencing holds for the
|
||||
// rest of the package's tests.
|
||||
func TestFlogSilenced(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
flog.SetOutput(&buf)
|
||||
t.Cleanup(func() { flog.SetOutput(io.Discard) })
|
||||
|
||||
flog.Info("captured-line")
|
||||
if !strings.Contains(buf.String(), "captured-line") {
|
||||
t.Fatalf("flog.SetOutput(buffer) did not capture output: %q", buf.String())
|
||||
}
|
||||
|
||||
flog.SetOutput(io.Discard)
|
||||
buf.Reset()
|
||||
flog.Info("should-be-discarded")
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("flog output not discarded: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// requireTEE skips the test unless the TPM is present and usable. On a Linux
|
||||
// machine with a TPM but a restrictive device owner (`/dev/tpmrm0` is `tss:tss`
|
||||
// by default), grant access with `sudo usermod -aG tss $USER` then re-login, or
|
||||
// run the test under sudo.
|
||||
func requireTEE(t *testing.T) {
|
||||
t.Helper()
|
||||
info, err := sksSigner{}.ProbeHardware(context.Background())
|
||||
if err != nil || !info.Available {
|
||||
reason := info.Reason
|
||||
if err != nil {
|
||||
reason = err.Error()
|
||||
}
|
||||
t.Skipf("TEE not available (%s)", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSKSSignerRoundTrip exercises the full registration→assertion contract
|
||||
// against the real TPM: create the key, read it back without creating, derive
|
||||
// the JWS alg + JWK, sign, and verify the fixed-width r||s output.
|
||||
func TestSKSSignerRoundTrip(t *testing.T) {
|
||||
requireTEE(t)
|
||||
|
||||
var s sksSigner
|
||||
ctx := context.Background()
|
||||
ref := KeyRef{Label: "larksuite-cli-test"}
|
||||
|
||||
// Best-effort cleanup so the test key does not linger in the TPM-sealed store.
|
||||
t.Cleanup(func() {
|
||||
if k, err := sks.NewKey(ref.Label, keyTag, false, true, nil); err == nil {
|
||||
_ = k.Remove()
|
||||
_ = k.Close()
|
||||
}
|
||||
})
|
||||
|
||||
pub, err := s.EnsureKey(ctx, ref)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKey: %v", err)
|
||||
}
|
||||
ecPub, ok := pub.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
t.Fatalf("EnsureKey returned %T, want *ecdsa.PublicKey", pub)
|
||||
}
|
||||
|
||||
// PublicKey (no-create) must return the same key bound at EnsureKey.
|
||||
pub2, err := s.PublicKey(ctx, ref)
|
||||
if err != nil {
|
||||
t.Fatalf("PublicKey: %v", err)
|
||||
}
|
||||
if !ecPub.Equal(pub2) {
|
||||
t.Fatal("PublicKey returned a different key than EnsureKey")
|
||||
}
|
||||
|
||||
// The JWT layer derives alg + JWK from the public key; both must work.
|
||||
if alg, err := AlgForKey(pub); err != nil || alg != AlgES256 {
|
||||
t.Fatalf("AlgForKey = %q, %v; want ES256", alg, err)
|
||||
}
|
||||
if _, err := PublicKeyJWK(pub); err != nil {
|
||||
t.Fatalf("PublicKeyJWK: %v", err)
|
||||
}
|
||||
|
||||
// Sign a representative JWS signing input and verify the converted r||s.
|
||||
input := []byte("eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJjbGkifQ")
|
||||
sig, alg, err := s.Sign(ctx, ref, input)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
if alg != AlgES256 {
|
||||
t.Fatalf("Sign alg = %q, want ES256", alg)
|
||||
}
|
||||
if len(sig) != 2*p256ByteLen {
|
||||
t.Fatalf("len(sig) = %d, want %d (fixed-width r||s)", len(sig), 2*p256ByteLen)
|
||||
}
|
||||
digest := sha256.Sum256(input)
|
||||
r := new(big.Int).SetBytes(sig[:p256ByteLen])
|
||||
ss := new(big.Int).SetBytes(sig[p256ByteLen:])
|
||||
if !ecdsa.Verify(ecPub, digest[:], r, ss) {
|
||||
t.Fatal("TPM signature did not verify against the public key")
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,8 @@ type EnumOption struct {
|
||||
}
|
||||
|
||||
// EnumOptions returns the field's allowed values paired with their descriptions
|
||||
// — from enum, or from options when enum is absent — coerced to the canonical
|
||||
// — from enum (with descriptions backfilled from options when the field carries
|
||||
// both forms), or from options when enum is absent — coerced to the canonical
|
||||
// type and ordered: numeric and boolean values are sorted; string values keep
|
||||
// source order (which can encode priority). Uncoercible literals are dropped.
|
||||
// Returns nil when the field declares no enum constraint.
|
||||
@@ -122,9 +123,14 @@ func (f Field) EnumOptions() []EnumOption {
|
||||
var out []EnumOption
|
||||
switch {
|
||||
case len(f.Enum) > 0:
|
||||
// key by raw literal so enum "1" and option 1 align across JSON types
|
||||
desc := make(map[string]string, len(f.Options))
|
||||
for _, o := range f.Options {
|
||||
desc[fmt.Sprintf("%v", o.Value)] = o.Description
|
||||
}
|
||||
for _, e := range f.Enum {
|
||||
if v, ok := coerceLiteral(ct, e); ok {
|
||||
out = append(out, EnumOption{Value: v})
|
||||
out = append(out, EnumOption{Value: v, Description: desc[fmt.Sprintf("%v", e)]})
|
||||
}
|
||||
}
|
||||
case len(f.Options) > 0:
|
||||
|
||||
@@ -80,6 +80,39 @@ func TestField_EnumOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_EnumOptions_BothEnumAndOptions(t *testing.T) {
|
||||
// enum is the value set; descriptions backfilled from options, empty where absent
|
||||
f := Field{Type: "string", Enum: []any{"1", "2", "3", "4", "6"}, Options: []Option{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
{Value: "6", Description: "subject"},
|
||||
}}
|
||||
want := []EnumOption{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
{Value: "3", Description: ""},
|
||||
{Value: "4", Description: ""},
|
||||
{Value: "6", Description: "subject"},
|
||||
}
|
||||
if got := f.EnumOptions(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("EnumOptions(enum+options) = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// enum values stored as strings match option values stored as numbers
|
||||
fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}, Options: []Option{
|
||||
{Value: 1, Description: "one"},
|
||||
{Value: 2, Description: "two"},
|
||||
}}
|
||||
wantI := []EnumOption{
|
||||
{Value: int64(1), Description: "one"},
|
||||
{Value: int64(2), Description: "two"},
|
||||
{Value: int64(10), Description: ""},
|
||||
}
|
||||
if got := fi.EnumOptions(); !reflect.DeepEqual(got, wantI) {
|
||||
t.Errorf("EnumOptions(integer enum+options) = %+v, want %+v", got, wantI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_Enum_NumberAndBoolean(t *testing.T) {
|
||||
// number: string-stored floats coerced to float64 and numerically sorted
|
||||
if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) {
|
||||
|
||||
@@ -472,6 +472,18 @@ func TestConvert_EnumDescriptions(t *testing.T) {
|
||||
if bare.EnumDescriptions != nil {
|
||||
t.Errorf("bare enum must have nil EnumDescriptions, got %v", bare.EnumDescriptions)
|
||||
}
|
||||
|
||||
// enum + options both present -> enumDescriptions backfilled, aligned, "" where absent
|
||||
both := Convert(meta.Field{Type: "string", Enum: []any{"1", "2", "3"}, Options: []meta.Option{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
}})
|
||||
if !reflect.DeepEqual(both.Enum, []interface{}{"1", "2", "3"}) {
|
||||
t.Errorf("both Enum = %v", both.Enum)
|
||||
}
|
||||
if !reflect.DeepEqual(both.EnumDescriptions, []string{"from", "to", ""}) {
|
||||
t.Errorf("both EnumDescriptions = %v, want [from to \"\"] aligned with enum", both.EnumDescriptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.56",
|
||||
"version": "1.0.57",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -24,10 +24,6 @@ build_target() {
|
||||
ext=".exe"
|
||||
fi
|
||||
|
||||
# The platform key signers are compiled in by build constraint, no tags:
|
||||
# darwin keychain (//go:build darwin) and linux/windows-amd64 TPM
|
||||
# (//go:build linux || (windows && amd64)). windows/arm64 arch-excludes the TPM
|
||||
# signer (go-ole has no arm64) and falls back to client_secret only.
|
||||
local output="$OUT_DIR/bin/lark-cli-${goos}-${goarch}${ext}"
|
||||
echo "Building ${goos}/${goarch} -> ${output}"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "$LDFLAGS" -o "$output" ./main.go
|
||||
|
||||
@@ -179,7 +179,10 @@ fi
|
||||
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
||||
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
|
||||
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
|
||||
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
|
||||
require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "PR quality summary should tolerate mutable workflow_run PR head metadata"
|
||||
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
|
||||
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
|
||||
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
|
||||
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
|
||||
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
|
||||
@@ -198,8 +201,9 @@ require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "se
|
||||
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
|
||||
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
|
||||
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
|
||||
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review must prefer workflow_run PR head when GitHub provides it"
|
||||
require_in_step "$verify_step" 'const targetHeadSha = eventHeadSha || run.head_sha' "semantic-review target PR head must come from the workflow_run event"
|
||||
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review should inspect workflow_run PR head metadata"
|
||||
require_in_step "$verify_step" 'const targetHeadSha = run.head_sha' "semantic-review target PR head must come from the completed CI run"
|
||||
require_in_step "$verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR head metadata"
|
||||
require_in_step "$verify_step" 'factsArtifactPattern' "semantic-review must use a base-bound facts artifact name"
|
||||
require_in_step "$verify_step" 'listWorkflowRunArtifacts' "semantic-review must read the workflow_run artifacts before resolving fallback base SHA"
|
||||
require_in_step "$verify_step" 'artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review must not let the artifact choose a different PR head"
|
||||
@@ -210,8 +214,8 @@ require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fall
|
||||
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
|
||||
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
|
||||
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review must reject mismatched event and artifact base SHAs"
|
||||
require_in_step "$verify_step" 'const baseSha = eventBaseSha || artifactBaseSha' "semantic-review fallback must use the CI-time artifact base SHA"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
|
||||
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
|
||||
require_in_step "$verify_step" 'pr.base.sha !== baseSha' "semantic-review must skip stale PR bases"
|
||||
require_in_step "$verify_step" 'core.setOutput("run_id"' "semantic-review must pass verified workflow run id to publisher"
|
||||
require_in_step "$verify_step" 'core.setOutput("head_repo_id"' "semantic-review must pass verified head repo id"
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -84,6 +85,9 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
// for dry-run "advisory preview" semantics).
|
||||
dry.Set("validation_error", err.Error())
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
dry.Set("oversize_html", hits)
|
||||
}
|
||||
dry.Set("file_count", len(candidates))
|
||||
var totalSize int64
|
||||
names := make([]string, 0, len(candidates))
|
||||
@@ -140,18 +144,22 @@ type appsHTMLPublishSpec struct {
|
||||
// per-environment .env.* files for every stage).
|
||||
const maxSensitiveListInError = 5
|
||||
|
||||
// truncatedJoin joins items with ", ", capping at max entries and appending
|
||||
// "(and N more)" for the remainder, so an inline error list stays readable when
|
||||
// a payload has many hits.
|
||||
func truncatedJoin(items []string, max int) string {
|
||||
if len(items) <= max {
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
return strings.Join(items[:max], ", ") + fmt.Sprintf(" (and %d more)", len(items)-max)
|
||||
}
|
||||
|
||||
// sensitiveCandidatesError builds the Validate-time rejection when --path
|
||||
// contains credential files and --allow-sensitive was not set.
|
||||
func sensitiveCandidatesError(hits []string) error {
|
||||
var sample string
|
||||
if len(hits) <= maxSensitiveListInError {
|
||||
sample = strings.Join(hits, ", ")
|
||||
} else {
|
||||
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
|
||||
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
|
||||
}
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
|
||||
"--path contains %d credential file(s) that should not be published: %s",
|
||||
len(hits), truncatedJoin(hits, maxSensitiveListInError)).
|
||||
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||||
}
|
||||
|
||||
@@ -168,6 +176,30 @@ var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
|
||||
// Mutable for tests.
|
||||
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
|
||||
|
||||
// maxHTMLPublishSingleHTMLFileBytes 单个 .html 文件上限,对齐妙搭服务端 10MB 约束。
|
||||
// 用 var 而非 const,便于单测调小覆盖拦截路径。
|
||||
var maxHTMLPublishSingleHTMLFileBytes int64 = 10 * 1024 * 1024
|
||||
|
||||
// oversizeHTMLFiles 返回 candidates 中扩展名为 .html(大小写不敏感)且单个 Size 超过
|
||||
// maxHTMLPublishSingleHTMLFileBytes 的 RelPath 列表。只针对 .html 文件,不波及图片/字体/JS。
|
||||
func oversizeHTMLFiles(candidates []htmlPublishCandidate) []string {
|
||||
var hits []string
|
||||
for _, c := range candidates {
|
||||
if strings.EqualFold(filepath.Ext(c.RelPath), ".html") && c.Size > maxHTMLPublishSingleHTMLFileBytes {
|
||||
hits = append(hits, c.RelPath)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// oversizeHTMLFilesError 构造单文件超限的 Validate 风格拒绝。
|
||||
func oversizeHTMLFilesError(hits []string) error {
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d HTML file(s) exceeding the %d bytes (10MB) per-file limit: %s",
|
||||
len(hits), maxHTMLPublishSingleHTMLFileBytes, truncatedJoin(hits, maxSensitiveListInError)).
|
||||
WithHint("split or trim oversized HTML file(s); the 10MB cap applies to each single .html file")
|
||||
}
|
||||
|
||||
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
|
||||
// 目录形态:根目录下必须有 index.html。
|
||||
// 单文件形态:文件名必须就是 index.html。
|
||||
@@ -190,6 +222,9 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
return nil, oversizeHTMLFilesError(hits)
|
||||
}
|
||||
var rawTotal int64
|
||||
for _, c := range candidates {
|
||||
rawTotal += c.Size
|
||||
|
||||
@@ -503,3 +503,82 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
t.Fatalf("client must not be called when raw cap hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeHTMLFiles(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
cands := []htmlPublishCandidate{
|
||||
{RelPath: "index.html", Size: 50},
|
||||
{RelPath: "big.html", Size: 4096},
|
||||
{RelPath: "BIG.HTML", Size: 4096}, // 大小写不敏感
|
||||
{RelPath: "huge.png", Size: 9000}, // 非 .html,忽略
|
||||
}
|
||||
hits := oversizeHTMLFiles(cands)
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("hits=%v, want [big.html BIG.HTML]", hits)
|
||||
}
|
||||
for _, h := range hits {
|
||||
if h == "huge.png" || h == "index.html" {
|
||||
t.Fatalf("unexpected hit %q", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHTMLPublishSingleHTMLFileBytes_Default(t *testing.T) {
|
||||
if maxHTMLPublishSingleHTMLFileBytes != 10*1024*1024 {
|
||||
t.Fatalf("default=%d, want %d (10MiB)", maxHTMLPublishSingleHTMLFileBytes, 10*1024*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected per-file oversize error")
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "big.html") || !strings.Contains(problem.Message, "10MB") {
|
||||
t.Fatalf("message=%q, want contains 'big.html' and '10MB'", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when an HTML file is oversize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,14 @@ var AppsInit = common.Shortcut{
|
||||
dry.Set("dir_error", err.Error())
|
||||
dir = defaultCloneDir(appID)
|
||||
} else if isAlreadyInitialized(dir) {
|
||||
dry.Set("already_initialized", true)
|
||||
if existing, e := ensureInitDirMatchesApp(dir, appID); e != nil {
|
||||
if existing != "" {
|
||||
dry.Set("app_id_mismatch", existing)
|
||||
}
|
||||
dry.Set("dir_error", e.Error())
|
||||
} else {
|
||||
dry.Set("already_initialized", true)
|
||||
}
|
||||
} else if e := ensureEmptyDir(dir); e != nil {
|
||||
dry.Set("dir_error", e.Error())
|
||||
}
|
||||
@@ -199,6 +206,61 @@ func isAlreadyInitialized(dir string) bool {
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// readMetaAppID 读取 <dir>/.spark/meta.json 的 app_id,用于判断目标目录是否同一个妙搭应用。
|
||||
// 返回 (appID, isSparkProject, err):
|
||||
// - meta.json 不存在 → ("", false, nil) 非妙搭工程
|
||||
// - 读取/解析失败(损坏/不可读) → ("", false, err) 无法确认是否妙搭工程
|
||||
// - 解析成功 → (trim 后的 app_id, true, nil)(app_id 缺失/为空时为 "")
|
||||
func readMetaAppID(dir string) (string, bool, error) {
|
||||
b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
var m struct {
|
||||
AppID string `json:"app_id"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return "", false, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
return strings.TrimSpace(m.AppID), true, nil
|
||||
}
|
||||
|
||||
// ensureInitDirMatchesApp 校验「已存在的目标目录」能否被 appID 安全复用:
|
||||
// - 不是妙搭工程(无 meta.json) → nil(交给 ensureEmptyDir 判空/非空)
|
||||
// - 是妙搭工程且 app_id 与 appID 一致 → nil(走已初始化短路,复用本地代码)
|
||||
// - 是妙搭工程但 app_id 不一致(含为空) → 报错,提示换目录
|
||||
// - meta.json 损坏/不可读,无法确认 → 报错(fail closed),提示换目录
|
||||
//
|
||||
// 返回值 existing 是目录里已存在的 app_id(仅"已是另一个 app"的拒绝场景非空),供调用方在
|
||||
// dry-run 里回填 app_id_mismatch,避免二次读 meta.json。
|
||||
func ensureInitDirMatchesApp(dir, appID string) (existing string, err error) {
|
||||
existing, isSpark, readErr := readMetaAppID(dir)
|
||||
if readErr != nil {
|
||||
return "", appsValidationParamError("--dir",
|
||||
"target directory %q already exists but its %s is unreadable or corrupted; cannot confirm it belongs to app %s, refusing to use it",
|
||||
dir, metaRelPath, appID).
|
||||
WithHint("choose a different --dir, or repair/remove the directory, before running +init").
|
||||
WithCause(readErr)
|
||||
}
|
||||
if !isSpark || existing == appID {
|
||||
return existing, nil
|
||||
}
|
||||
if existing == "" {
|
||||
// meta 存在但缺 app_id:更可能是同一应用上次 +init 中断留下的半成品,而非另一个 app。
|
||||
return "", appsValidationParamError("--dir",
|
||||
"target directory %q has a %s without an app_id; cannot confirm it belongs to app %s, refusing to use it",
|
||||
dir, metaRelPath, appID).
|
||||
WithHint("remove the directory and re-run +init, or choose a different --dir")
|
||||
}
|
||||
return existing, appsValidationParamError("--dir",
|
||||
"target directory %q is already initialized for a different app (%s); refusing to initialize app %s into it",
|
||||
dir, existing, appID).
|
||||
WithHint("choose a different --dir (or cd into the matching project) before running +init")
|
||||
}
|
||||
|
||||
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
|
||||
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
|
||||
// the file does not exist, this is a no-op (we never create it).
|
||||
@@ -378,6 +440,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 异 app 目录护栏:拒绝把当前 app 初始化进另一个 app 的已初始化工程。
|
||||
if _, err := ensureInitDirMatchesApp(dir, appID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
// initialized app repo -> skip clone/scaffold/commit, but still refresh
|
||||
// the local env so a re-run picks up the latest startup env vars.
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"whatever"}`), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
|
||||
@@ -394,6 +394,40 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_AlreadyInitialized_AppIDMismatch(t *testing.T) {
|
||||
dir := relCloneDir(t)
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 目录是 app_other 的工程,却用 --app-id app_x 初始化 → 必须报错且不拉 env。
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_other"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("mismatched app_id must error")
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--dir" {
|
||||
t.Fatalf("expected *errs.ValidationError with Param=--dir, got %T param=%v", err, ve)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "different app") {
|
||||
t.Fatalf("message=%q, want 'different app'", problem.Message)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if containsAll(c, "+env-pull") || containsAll(c, "git", "clone") {
|
||||
t.Errorf("mismatch must not run env-pull/clone; got %v", f.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_HappyPathCleanTree(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||||
@@ -1468,6 +1502,125 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMetaAppID(t *testing.T) {
|
||||
writeMeta := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// 不存在 meta.json → ("", false, nil)
|
||||
if got, ok, err := readMetaAppID(t.TempDir()); ok || got != "" || err != nil {
|
||||
t.Fatalf("no meta: got (%q,%v,%v), want (\"\",false,nil)", got, ok, err)
|
||||
}
|
||||
// 存在且有 app_id → (app_id, true, nil)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{"app_id":"app_a"}`)); !ok || got != "app_a" || err != nil {
|
||||
t.Fatalf("with app_id: got (%q,%v,%v), want (\"app_a\",true,nil)", got, ok, err)
|
||||
}
|
||||
// 存在但 app_id 空 → ("", true, nil)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{"name":"x"}`)); !ok || got != "" || err != nil {
|
||||
t.Fatalf("empty app_id: got (%q,%v,%v), want (\"\",true,nil)", got, ok, err)
|
||||
}
|
||||
// 存在但坏 JSON → ("", false, err)(无法确认)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{not json`)); ok || got != "" || err == nil {
|
||||
t.Fatalf("bad json: got (%q,%v,err=%v), want (\"\",false,non-nil)", got, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInitDirMatchesApp(t *testing.T) {
|
||||
writeMeta := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// 无 meta(非妙搭工程)→ nil(交给 ensureEmptyDir)
|
||||
if _, err := ensureInitDirMatchesApp(t.TempDir(), "app_x"); err != nil {
|
||||
t.Fatalf("no meta should pass: %v", err)
|
||||
}
|
||||
// 同 app_id → (app_id, nil)(走已初始化短路)
|
||||
if existing, err := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_x"}`), "app_x"); err != nil || existing != "app_x" {
|
||||
t.Fatalf("same app should pass: existing=%q err=%v", existing, err)
|
||||
}
|
||||
|
||||
// 不同 app_id → error(换目录),返回 existing=app_other;断言 typed metadata(subtype/param)
|
||||
existing, errMismatch := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_other"}`), "app_x")
|
||||
if errMismatch == nil {
|
||||
t.Fatal("different app should error")
|
||||
}
|
||||
if existing != "app_other" {
|
||||
t.Fatalf("mismatch should return existing app_id, got %q", existing)
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, errMismatch) // 已校验 Category==Validation
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(errMismatch, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", errMismatch)
|
||||
}
|
||||
if ve.Param != "--dir" {
|
||||
t.Fatalf("param=%q, want --dir", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "different app") || !strings.Contains(problem.Message, "app_other") {
|
||||
t.Fatalf("message=%q, want 'different app' and 'app_other'", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "different --dir") {
|
||||
t.Fatalf("hint=%q, want 'different --dir'", problem.Hint)
|
||||
}
|
||||
|
||||
// 空 app_id(缺 app_id 标记的半成品)→ error,独立文案(非 "different app"),返回 existing=""
|
||||
emptyExisting, errEmpty := ensureInitDirMatchesApp(writeMeta(t, `{"name":"x"}`), "app_x")
|
||||
if errEmpty == nil {
|
||||
t.Fatal("empty meta app_id should error (cannot confirm same app)")
|
||||
}
|
||||
if emptyExisting != "" {
|
||||
t.Fatalf("empty app_id should return existing=\"\", got %q", emptyExisting)
|
||||
}
|
||||
pEmpty := requireAppsValidationProblem(t, errEmpty)
|
||||
if pEmpty.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("empty subtype=%q, want %q", pEmpty.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(pEmpty.Message, "without an app_id") {
|
||||
t.Fatalf("empty app_id should have its own message, msg=%q", pEmpty.Message)
|
||||
}
|
||||
if strings.Contains(pEmpty.Message, "different app") {
|
||||
t.Fatalf("empty app_id must not reuse the different-app wording, msg=%q", pEmpty.Message)
|
||||
}
|
||||
|
||||
// meta 损坏/不可读 → error(fail closed),返回 existing=""
|
||||
badExisting, errBad := ensureInitDirMatchesApp(writeMeta(t, `{not json`), "app_x")
|
||||
if errBad == nil {
|
||||
t.Fatal("corrupted meta should fail closed")
|
||||
}
|
||||
if badExisting != "" {
|
||||
t.Fatalf("corrupted should return existing=\"\", got %q", badExisting)
|
||||
}
|
||||
pBad := requireAppsValidationProblem(t, errBad)
|
||||
if pBad.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("corrupted subtype=%q, want %q", pBad.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(pBad.Message, "unreadable or corrupted") {
|
||||
t.Fatalf("corrupted meta msg=%q, want 'unreadable or corrupted'", pBad.Message)
|
||||
}
|
||||
var veBad *errs.ValidationError
|
||||
if !errors.As(errBad, &veBad) || veBad.Param != "--dir" {
|
||||
t.Fatalf("corrupted: expected ValidationError Param=--dir, got %T param=%v", errBad, veBad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
|
||||
// classification of an external-tool failure: a failing git subprocess
|
||||
// surfaces as internal/external_tool with the cause preserved.
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
)
|
||||
|
||||
const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info"
|
||||
const gitCredentialHelperReportedShortcut = appsService + ":+git-credential-helper"
|
||||
|
||||
// gitCredentialIssueHint is the actionable next-step attached to a failed
|
||||
// Git-credential issuance. A 5xx is flagged retryable separately at the call site.
|
||||
@@ -302,7 +304,12 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: issuePath(appID),
|
||||
}
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
|
||||
ctx = contextWithGitCredentialHelperShortcut(ctx)
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
if optFn := cmdutil.ShortcutHeaderOpts(ctx); optFn != nil {
|
||||
opts = append(opts, optFn)
|
||||
}
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser, opts...)
|
||||
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
|
||||
Brand: string(cfg.Brand),
|
||||
AppID: cfg.AppID,
|
||||
@@ -314,6 +321,13 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
return issuedFromData(appID, data)
|
||||
}
|
||||
|
||||
func contextWithGitCredentialHelperShortcut(ctx context.Context) context.Context {
|
||||
if _, ok := cmdutil.ShortcutNameFromContext(ctx); ok {
|
||||
return ctx
|
||||
}
|
||||
return cmdutil.ContextWithShortcut(ctx, gitCredentialHelperReportedShortcut, uuid.New().String())
|
||||
}
|
||||
|
||||
func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, action string) error {
|
||||
if f == nil || f.IOStreams == nil {
|
||||
return nil
|
||||
|
||||
@@ -825,7 +825,7 @@ func TestRunGitCredentialHelperActions(t *testing.T) {
|
||||
func TestFactoryIssuerBranches(t *testing.T) {
|
||||
factory, _, reg := newAppsExecuteFactory(t)
|
||||
expiresAt := time.Now().Add(24 * time.Hour).Unix()
|
||||
reg.Register(&httpmock.Stub{
|
||||
issueStub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
|
||||
Body: map[string]interface{}{
|
||||
@@ -836,7 +836,8 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
"StatusCode": 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(issueStub)
|
||||
issued, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("factory issuer returned error: %v", err)
|
||||
@@ -844,6 +845,12 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
if issued.PAT != "pat-token" {
|
||||
t.Fatalf("PAT = %q", issued.PAT)
|
||||
}
|
||||
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderShortcut); got != gitCredentialHelperReportedShortcut {
|
||||
t.Fatalf("%s = %q, want %q", cmdutil.HeaderShortcut, got, gitCredentialHelperReportedShortcut)
|
||||
}
|
||||
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderExecutionId); got == "" {
|
||||
t.Fatalf("%s header missing", cmdutil.HeaderExecutionId)
|
||||
}
|
||||
|
||||
factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") }
|
||||
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
|
||||
@@ -880,6 +887,20 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextWithGitCredentialHelperShortcutPreservesExistingShortcut(t *testing.T) {
|
||||
ctx := cmdutil.ContextWithShortcut(context.Background(), "apps:+git-credential-init", "exec-existing")
|
||||
got := contextWithGitCredentialHelperShortcut(ctx)
|
||||
|
||||
name, ok := cmdutil.ShortcutNameFromContext(got)
|
||||
if !ok || name != "apps:+git-credential-init" {
|
||||
t.Fatalf("shortcut = %q ok=%v, want existing shortcut", name, ok)
|
||||
}
|
||||
executionID, ok := cmdutil.ExecutionIdFromContext(got)
|
||||
if !ok || executionID != "exec-existing" {
|
||||
t.Fatalf("execution id = %q ok=%v, want existing execution id", executionID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCredentialHelpersAndParsers(t *testing.T) {
|
||||
if issuePath(" app/with space ") != "/open-apis/spark/v1/apps/app%2Fwith%20space/git_info" {
|
||||
t.Fatalf("issuePath escaped incorrectly: %s", issuePath(" app/with space "))
|
||||
|
||||
@@ -85,6 +85,7 @@ type searchUserAPIData struct {
|
||||
Items []searchUserAPIItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
Notice string `json:"notice"`
|
||||
}
|
||||
|
||||
type searchUserAPIItem struct {
|
||||
@@ -126,6 +127,7 @@ type searchUser struct {
|
||||
type searchUserResponse struct {
|
||||
Users []searchUser `json:"users"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
var ContactSearchUser = common.Shortcut{
|
||||
@@ -189,6 +191,7 @@ var ContactSearchUser = common.Shortcut{
|
||||
Execute: executeSearchUser,
|
||||
}
|
||||
|
||||
// executeSearchUser dispatches contact search to single-query or fanout mode.
|
||||
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return executeSearchUserFanout(ctx, runtime)
|
||||
@@ -196,6 +199,7 @@ func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) erro
|
||||
return executeSearchUserSingle(ctx, runtime)
|
||||
}
|
||||
|
||||
// executeSearchUserSingle performs one contact search and preserves server notices.
|
||||
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildSearchUserBody(runtime)
|
||||
if err != nil {
|
||||
@@ -222,7 +226,7 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore}
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore, Notice: respData.Notice}
|
||||
|
||||
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
|
||||
if len(users) == 0 {
|
||||
|
||||
@@ -45,22 +45,17 @@ type fanoutResult struct {
|
||||
Query string
|
||||
Users []searchUser
|
||||
HasMore bool
|
||||
Notice string
|
||||
ErrMsg string // empty = success
|
||||
Err error // original failure, kept for typed all-failed propagation
|
||||
}
|
||||
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
|
||||
// because that summary lives on stderr and never corrupts the csv stream on
|
||||
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
|
||||
// for its refine hint, so adding csv here doesn't regress that path.
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line.
|
||||
func isFanoutSummaryFormat(format string) bool {
|
||||
return format == "pretty" || format == "table" || format == "csv"
|
||||
}
|
||||
|
||||
// runOneQuery converts every failure mode (transport, HTTP status, parse,
|
||||
// API code) into an ErrMsg string instead of returning a Go error. The
|
||||
// fanout dispatcher (Task 6) relies on this so a single failed query never
|
||||
// short-circuits the remaining workers.
|
||||
// runOneQuery converts one fanout request into either users or an error summary.
|
||||
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
|
||||
filter *searchUserAPIFilter) fanoutResult {
|
||||
// Pre-check ctx so queued workers see cancellation before issuing a
|
||||
@@ -94,9 +89,10 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore, Notice: respData.Notice}
|
||||
}
|
||||
|
||||
// fanoutErrorResult records a failed fanout query without stopping other workers.
|
||||
func fanoutErrorResult(index int, query string, err error) fanoutResult {
|
||||
if err == nil {
|
||||
return fanoutResult{Index: index, Query: query}
|
||||
@@ -113,17 +109,16 @@ type querySummary struct {
|
||||
Query string `json:"query"`
|
||||
Error string `json:"error,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
type fanoutResponse struct {
|
||||
Users []fanoutUser `json:"users"`
|
||||
Queries []querySummary `json:"queries"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
// buildFanoutResponse walks results by Index (input order), flattens users[]
|
||||
// with matched_query, lists every input in queries[] (including successes),
|
||||
// and returns an error only when every query failed. The error wraps the
|
||||
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
|
||||
// buildFanoutResponse flattens ordered fanout results and fails only when all queries fail.
|
||||
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
|
||||
indexed := make([]fanoutResult, len(queries))
|
||||
for _, r := range results {
|
||||
@@ -142,6 +137,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
Query: queries[i],
|
||||
Error: r.ErrMsg,
|
||||
HasMore: r.HasMore,
|
||||
Notice: r.Notice,
|
||||
})
|
||||
if r.ErrMsg != "" {
|
||||
failed++
|
||||
@@ -152,6 +148,9 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
}
|
||||
continue
|
||||
}
|
||||
if out.Notice == "" {
|
||||
out.Notice = r.Notice
|
||||
}
|
||||
for _, u := range r.Users {
|
||||
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
|
||||
}
|
||||
|
||||
@@ -562,6 +562,7 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// searchUserStub returns a representative user search response with a notice.
|
||||
func searchUserStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -569,6 +570,7 @@ func searchUserStub() *httpmock.Stub {
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "ou_a",
|
||||
@@ -590,6 +592,7 @@ func searchUserStub() *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchUser_Integration_PrettyRendersExpectedColumns verifies human output columns.
|
||||
func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(searchUserStub())
|
||||
@@ -614,6 +617,7 @@ func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchUser_Integration_JSONStructuredFields verifies normalized JSON and notices.
|
||||
func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(searchUserStub())
|
||||
@@ -631,6 +635,9 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("envelope.data: expected object, got %v\nraw=%s", got["data"], stdout.String())
|
||||
}
|
||||
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("data.notice = %v", data["notice"])
|
||||
}
|
||||
users, _ := data["users"].([]interface{})
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("users: expected 1, got %d (output=%s)", len(users), stdout.String())
|
||||
@@ -1358,6 +1365,7 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanout_FilterAppliedToEachQuery verifies shared fanout filters reach every request.
|
||||
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
stub := &httpmock.Stub{
|
||||
@@ -1399,6 +1407,7 @@ func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanout_PartialFailure_ExitZero verifies partial fanout failures keep notices.
|
||||
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1406,6 +1415,7 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
@@ -1432,10 +1442,17 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
if len(users) != 1 {
|
||||
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
|
||||
}
|
||||
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("data.notice = %v", data["notice"])
|
||||
}
|
||||
queries := data["queries"].([]interface{})
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("queries: expected 2, got %d", len(queries))
|
||||
}
|
||||
q0 := queries[0].(map[string]interface{})
|
||||
if q0["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("queries[0].notice = %v", q0["notice"])
|
||||
}
|
||||
q1 := queries[1].(map[string]interface{})
|
||||
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
|
||||
t.Errorf("queries[1].error: got %q", q1["error"])
|
||||
|
||||
@@ -74,6 +74,9 @@ var DocsSearch = common.Shortcut{
|
||||
"page_token": data["page_token"],
|
||||
"results": normalizedItems,
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
resultData["notice"] = notice
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
|
||||
if len(normalizedItems) == 0 {
|
||||
|
||||
@@ -7,8 +7,48 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestDocsSearchExecutePassesThroughNotice verifies docs +search preserves notices.
|
||||
func TestDocsSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-search-notice"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"res_units": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := mountAndRunDocs(t, DocsSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("DocsSearch.Execute() error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddIsoTimeFieldsSupportsJSONNumber verifies JSON numbers get ISO fields.
|
||||
func TestAddIsoTimeFieldsSupportsJSONNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ const (
|
||||
var DriveAddComment = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-comment",
|
||||
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
|
||||
Description: "Add a comment to doc/docx/file/sheet/slides/base(bitable); file targets support selected extensions and full comments only",
|
||||
Risk: "write",
|
||||
Scopes: []string{
|
||||
"drive:drive.metadata:readonly",
|
||||
@@ -131,12 +131,12 @@ var DriveAddComment = common.Shortcut{
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
|
||||
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides/base/bitable URL, or wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides, bitable, base (required when --doc is a bare token; auto-detected for URLs; use bitable as the wire value, base is accepted as a compatibility alias)", Enum: []string{"doc", "docx", "file", "sheet", "slides", "bitable", "base"}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
|
||||
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6); for slides: <slide-block-type>!<xml-id> (e.g. shape!bPq)"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell>; for slides: <slide-block-type>!<xml-id>; for base(bitable): <table-id>!<record-id>!<view-id>"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
@@ -148,6 +148,17 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
if docRef.Kind == "base" {
|
||||
if runtime.Bool("full-comment") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--full-comment")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--selection-with-ellipsis")
|
||||
}
|
||||
_, err := parseBaseCommentAnchor(runtime)
|
||||
return err
|
||||
}
|
||||
|
||||
// Sheet comment validation.
|
||||
if docRef.Kind == "sheet" {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
@@ -188,7 +199,7 @@ var DriveAddComment = common.Shortcut{
|
||||
return validateFileCommentMode(mode, "")
|
||||
}
|
||||
if mode == commentModeLocal && docRef.Kind == "doc" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -215,6 +226,23 @@ var DriveAddComment = common.Shortcut{
|
||||
resolvedToken = target.FileToken
|
||||
}
|
||||
|
||||
if resolvedKind == "base" {
|
||||
anchor, err := parseBaseCommentAnchor(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
commentBody := buildBaseCommentCreateV2Request(replyElements, anchor)
|
||||
desc := "1-step request: create base(bitable) record-local comment"
|
||||
if isWiki {
|
||||
desc = "2-step orchestration: resolve wiki -> create base(bitable) record-local comment"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/:file_token/new_comments").
|
||||
Body(commentBody).
|
||||
Set("file_token", resolvedToken)
|
||||
}
|
||||
|
||||
// Sheet comment dry-run.
|
||||
if resolvedKind == "sheet" {
|
||||
anchor, _ := parseSheetCellRef(blockID)
|
||||
@@ -352,6 +380,14 @@ var DriveAddComment = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Sheet comment: direct URL or token fast path.
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if docRef.Kind == "base" {
|
||||
return executeBaseComment(runtime, resolvedCommentTarget{
|
||||
DocID: docRef.Token,
|
||||
FileToken: docRef.Token,
|
||||
FileType: "base",
|
||||
ResolvedBy: "base",
|
||||
})
|
||||
}
|
||||
if docRef.Kind == "sheet" {
|
||||
return executeSheetComment(runtime, docRef)
|
||||
}
|
||||
@@ -375,6 +411,9 @@ var DriveAddComment = common.Shortcut{
|
||||
if target.FileType == "slides" {
|
||||
return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken})
|
||||
}
|
||||
if target.FileType == "base" {
|
||||
return executeBaseComment(runtime, target)
|
||||
}
|
||||
if target.FileType == "file" {
|
||||
return executeFileComment(runtime, target)
|
||||
}
|
||||
@@ -482,6 +521,12 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
if token, ok := extractURLToken(raw, "/sheets/"); ok {
|
||||
return commentDocRef{Kind: "sheet", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/base/"); ok {
|
||||
return commentDocRef{Kind: "base", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/bitable/"); ok {
|
||||
return commentDocRef{Kind: "base", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/file/"); ok {
|
||||
return commentDocRef{Kind: "file", Token: token}, nil
|
||||
}
|
||||
@@ -495,7 +540,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
return commentDocRef{Kind: "doc", Token: token}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw).WithParam("--doc")
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides/base/bitable URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw).WithParam("--doc")
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a token with --type, or a wiki URL", raw).WithParam("--doc")
|
||||
@@ -504,7 +549,10 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
// Bare token: --type is required.
|
||||
docType = strings.TrimSpace(docType)
|
||||
if docType == "" {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)").WithParam("--type")
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides, bitable, base; use bitable as the wire value, base is accepted as a compatibility alias)").WithParam("--type")
|
||||
}
|
||||
if docType == "bitable" || docType == "base" {
|
||||
return commentDocRef{Kind: "base", Token: raw}, nil
|
||||
}
|
||||
return commentDocRef{Kind: docType, Token: raw}, nil
|
||||
}
|
||||
@@ -515,11 +563,11 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" || docRef.Kind == "base" {
|
||||
if mode == commentModeLocal {
|
||||
switch docRef.Kind {
|
||||
case "doc":
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
|
||||
case "file":
|
||||
if err := validateFileCommentMode(mode, ""); err != nil {
|
||||
return resolvedCommentTarget{}, err
|
||||
@@ -557,6 +605,22 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>", objType)
|
||||
}
|
||||
if objType == "bitable" || objType == "base" {
|
||||
if runtime.Bool("full-comment") {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--full-comment")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--selection-with-ellipsis")
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to base: %s\n", common.MaskToken(objToken))
|
||||
return resolvedCommentTarget{
|
||||
DocID: objToken,
|
||||
FileToken: objToken,
|
||||
FileType: "base",
|
||||
ResolvedBy: "wiki",
|
||||
WikiToken: docRef.Token,
|
||||
}, nil
|
||||
}
|
||||
if objType == "sheet" {
|
||||
// Sheet comments are handled via the sheet fast path in Execute.
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -592,10 +656,10 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
}, nil
|
||||
}
|
||||
if mode == commentModeLocal && objType != "docx" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, slides, and base(bitable); for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>, for base use --block-id <table-id>!<record-id>!<view-id>", objType)
|
||||
}
|
||||
if mode == commentModeFull && objType != "docx" && objType != "doc" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides/base(bitable)", objType)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -787,6 +851,12 @@ type sheetAnchor struct {
|
||||
Row int
|
||||
}
|
||||
|
||||
type baseAnchor struct {
|
||||
BlockID string
|
||||
BaseRecordID string
|
||||
BaseViewID string
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
@@ -813,6 +883,18 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply
|
||||
return body
|
||||
}
|
||||
|
||||
func buildBaseCommentCreateV2Request(replyElements []map[string]interface{}, anchor baseAnchor) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"file_type": "bitable",
|
||||
"reply_elements": replyElements,
|
||||
"anchor": map[string]interface{}{
|
||||
"block_id": anchor.BlockID,
|
||||
"base_record_id": anchor.BaseRecordID,
|
||||
"base_view_id": anchor.BaseViewID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func anchorBlockIDForDryRun(blockID string) string {
|
||||
if strings.TrimSpace(blockID) != "" {
|
||||
return strings.TrimSpace(blockID)
|
||||
@@ -820,6 +902,26 @@ func anchorBlockIDForDryRun(blockID string) string {
|
||||
return "<anchor_block_id>"
|
||||
}
|
||||
|
||||
func parseBaseCommentAnchor(runtime *common.RuntimeContext) (baseAnchor, error) {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for base(bitable) record-local comments (format: <table-id>!<record-id>!<view-id>, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R)").WithParam("--block-id")
|
||||
}
|
||||
return parseBaseBlockRef(blockID)
|
||||
}
|
||||
|
||||
func parseBaseBlockRef(blockID string) (baseAnchor, error) {
|
||||
parts := strings.Split(strings.TrimSpace(blockID), "!")
|
||||
if len(parts) != 3 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" {
|
||||
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "base(bitable) record-local comments require --block-id in <table-id>!<record-id>!<view-id> format, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R").WithParam("--block-id")
|
||||
}
|
||||
return baseAnchor{
|
||||
BlockID: strings.TrimSpace(parts[0]),
|
||||
BaseRecordID: strings.TrimSpace(parts[1]),
|
||||
BaseViewID: strings.TrimSpace(parts[2]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseSlidesBlockRef(blockID string) (string, string, error) {
|
||||
blockID = strings.TrimSpace(blockID)
|
||||
if blockID == "" {
|
||||
@@ -1030,6 +1132,53 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
anchor, err := parseBaseCommentAnchor(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
|
||||
requestBody := buildBaseCommentCreateV2Request(replyElements, anchor)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating base(bitable) record-local comment in %s (table=%s, record=%s, view=%s)\n",
|
||||
common.MaskToken(target.FileToken), anchor.BlockID, anchor.BaseRecordID, anchor.BaseViewID)
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": target.FileToken,
|
||||
"file_type": "bitable",
|
||||
"resolved_by": target.ResolvedBy,
|
||||
"comment_mode": "base_record",
|
||||
"base_block_id": anchor.BlockID,
|
||||
"base_record_id": anchor.BaseRecordID,
|
||||
"base_view_id": anchor.BaseViewID,
|
||||
}
|
||||
if commentID := data["comment_id"]; commentID != nil {
|
||||
out["comment_id"] = commentID
|
||||
}
|
||||
if replyID := data["reply_id"]; replyID != nil {
|
||||
out["reply_id"] = replyID
|
||||
}
|
||||
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
|
||||
out["created_at"] = createdAt
|
||||
}
|
||||
if target.WikiToken != "" {
|
||||
out["wiki_token"] = target.WikiToken
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
|
||||
@@ -133,6 +133,20 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantKind: "file",
|
||||
wantToken: "fileToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type bitable",
|
||||
input: "baseToken",
|
||||
docType: "bitable",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type base alias",
|
||||
input: "baseToken",
|
||||
docType: "base",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken",
|
||||
},
|
||||
{
|
||||
name: "raw token without type",
|
||||
input: "xxxxxx",
|
||||
@@ -156,6 +170,18 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantKind: "file",
|
||||
wantToken: "boxcn123",
|
||||
},
|
||||
{
|
||||
name: "base url",
|
||||
input: "https://example.larksuite.com/base/baseToken123?table=tbl1",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken123",
|
||||
},
|
||||
{
|
||||
name: "bitable url",
|
||||
input: "https://example.larksuite.com/bitable/baseToken456?table=tbl1",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken456",
|
||||
},
|
||||
{
|
||||
name: "unsupported url",
|
||||
input: "https://example.com/not-a-doc",
|
||||
@@ -726,6 +752,35 @@ func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBaseCommentCreateV2Request(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "base comment"},
|
||||
}
|
||||
got := buildBaseCommentCreateV2Request(replyElements, baseAnchor{
|
||||
BlockID: "tbl9mp6fj9kDKHQV",
|
||||
BaseRecordID: "recBIBgGmb",
|
||||
BaseViewID: "vewc46MG1R",
|
||||
})
|
||||
|
||||
if got["file_type"] != "bitable" {
|
||||
t.Fatalf("expected file_type bitable, got %#v", got["file_type"])
|
||||
}
|
||||
anchor, ok := got["anchor"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected anchor map, got %#v", got["anchor"])
|
||||
}
|
||||
if anchor["block_id"] != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("expected block_id tbl9mp6fj9kDKHQV, got %#v", anchor["block_id"])
|
||||
}
|
||||
if anchor["base_record_id"] != "recBIBgGmb" {
|
||||
t.Fatalf("expected base_record_id recBIBgGmb, got %#v", anchor["base_record_id"])
|
||||
}
|
||||
if anchor["base_view_id"] != "vewc46MG1R" {
|
||||
t.Fatalf("expected base_view_id vewc46MG1R, got %#v", anchor["base_view_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet cell ref parsing tests ────────────────────────────────────────────
|
||||
|
||||
func TestParseSheetCellRef(t *testing.T) {
|
||||
@@ -985,6 +1040,78 @@ func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentValidateMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentValidateMalformedBlockID(t *testing.T) {
|
||||
cases := []string{
|
||||
"tbl9mp6fj9kDKHQV",
|
||||
"tbl9mp6fj9kDKHQV!recBIBgGmb",
|
||||
"tbl9mp6fj9kDKHQV!!vewc46MG1R",
|
||||
}
|
||||
for _, blockID := range cases {
|
||||
t.Run(blockID, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", blockID,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "<table-id>!<record-id>!<view-id>") {
|
||||
t.Fatalf("expected block-id format error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentValidateRejectsIncompatibleFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "full comment",
|
||||
args: []string{"--full-comment"},
|
||||
wantErr: "--full-comment is not applicable for base(bitable) comments",
|
||||
},
|
||||
{
|
||||
name: "selection",
|
||||
args: []string{"--selection-with-ellipsis", "some text"},
|
||||
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
args := []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}
|
||||
args = append(args, tc.args...)
|
||||
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slides comment execute tests ────────────────────────────────────────────
|
||||
|
||||
func TestSlidesCommentExecuteSuccess(t *testing.T) {
|
||||
@@ -1195,6 +1322,87 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/baseToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"comment_id": "baseComment123",
|
||||
"reply_id": "baseReply123",
|
||||
"created_at": 1700000000,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"请看这条记录"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var requestBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &requestBody); err != nil {
|
||||
t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(createStub.CapturedBody))
|
||||
}
|
||||
if got := mustStringField(t, requestBody, "file_type", "request.file_type"); got != "bitable" {
|
||||
t.Fatalf("request file_type = %q, want bitable", got)
|
||||
}
|
||||
anchor := mustMapValue(t, requestBody["anchor"], "request.anchor")
|
||||
if got := mustStringField(t, anchor, "block_id", "request.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("request block_id = %q, want tbl9mp6fj9kDKHQV", got)
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_record_id", "request.anchor.base_record_id"); got != "recBIBgGmb" {
|
||||
t.Fatalf("request base_record_id = %q, want recBIBgGmb", got)
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_view_id", "request.anchor.base_view_id"); got != "vewc46MG1R" {
|
||||
t.Fatalf("request base_view_id = %q, want vewc46MG1R", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "comment_mode", "data.comment_mode"); got != "base_record" {
|
||||
t.Fatalf("stdout comment_mode = %q, want base_record\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "baseReply123" {
|
||||
t.Fatalf("stdout reply_id = %q, want baseReply123\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentExecuteBareToken(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/baseBareToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "baseBareComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "baseBareToken",
|
||||
"--type", "bitable",
|
||||
"--content", `[{"type":"text","text":"ok"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "baseBareComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1433,6 +1641,40 @@ func TestDryRunSlidesDirectURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunBaseDirectURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "record-local comment") {
|
||||
t.Fatalf("dry-run output missing record-local comment: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "api")
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
|
||||
if got := mustStringField(t, body, "file_type", "api[0].body.file_type"); got != "bitable" {
|
||||
t.Fatalf("dry-run body.file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, anchor, "block_id", "api[0].body.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("dry-run body.anchor.block_id = %q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_record_id", "api[0].body.anchor.base_record_id"); got != "recBIBgGmb" {
|
||||
t.Fatalf("dry-run body.anchor.base_record_id = %q, want recBIBgGmb\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_view_id", "api[0].body.anchor.base_view_id"); got != "vewc46MG1R" {
|
||||
t.Fatalf("dry-run body.anchor.base_view_id = %q, want vewc46MG1R\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToSlides(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1636,25 +1878,92 @@ func TestResolveWikiToDocxFullComment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToUnsupportedType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
func TestResolveWikiToBaseComment(t *testing.T) {
|
||||
for _, objType := range []string{"bitable", "base"} {
|
||||
t.Run(objType, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": objType, "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/bitToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiBaseComment", "reply_id": "wikiBaseReply"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiBaseComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiToken" {
|
||||
t.Fatalf("stdout wiki_token = %q, want wikiToken\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToBaseRejectsIncompatibleFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "full comment",
|
||||
args: []string{"--full-comment"},
|
||||
wantErr: "--full-comment is not applicable for base(bitable) comments",
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") {
|
||||
t.Fatalf("expected unsupported type error, got: %v", err)
|
||||
{
|
||||
name: "selection",
|
||||
args: []string{"--selection-with-ellipsis", "some text"},
|
||||
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}
|
||||
args = append(args, tc.args...)
|
||||
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1735,7 +2044,7 @@ func TestDocOldFormatLocalCommentRejected(t *testing.T) {
|
||||
"--block-id", "blk_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, and slides") {
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, slides, and base(bitable)") {
|
||||
t.Fatalf("expected local comment rejection for old doc, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,9 @@ var DriveSearch = common.Shortcut{
|
||||
"page_token": data["page_token"],
|
||||
"results": normalizedItems,
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
resultData["notice"] = notice
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
|
||||
renderDriveSearchTable(w, data, normalizedItems)
|
||||
|
||||
@@ -14,12 +14,49 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestClampOpenedTimeWindow covers the 3-month / 1-year boundary logic that
|
||||
// narrows --opened-since / --opened-until and generates the multi-slice notice.
|
||||
// TestDriveSearchExecutePassesThroughNotice verifies drive +search preserves notices.
|
||||
func TestDriveSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"res_units": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := mountAndRunDrive(t, DriveSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("DriveSearch.Execute() error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampOpenedTimeWindow covers opened-time clamping and slice notices.
|
||||
func TestClampOpenedTimeWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -26,9 +26,7 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
|
||||
// command whose flags are populated from the provided string and bool maps,
|
||||
// for unit-testing shortcut bodies, validators, and dry-run shapes.
|
||||
// newTestRuntimeContext builds a RuntimeContext with string and bool test flags.
|
||||
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -59,9 +57,38 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// newMessagesSearchTestRuntimeContext is the messages-search variant of
|
||||
// newTestRuntimeContext: registers the search-specific --page-size flag
|
||||
// before applying caller-provided values.
|
||||
// newChatSearchTestRuntimeContext builds a chat-search RuntimeContext with typed flags.
|
||||
func newChatSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
for name := range stringFlags {
|
||||
if name == "page-size" {
|
||||
continue
|
||||
}
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for name, val := range stringFlags {
|
||||
if err := cmd.Flags().Set(name, val); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, val := range boolFlags {
|
||||
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// newMessagesSearchTestRuntimeContext builds a messages-search RuntimeContext.
|
||||
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -231,6 +258,7 @@ func TestIsMediaKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutValidateBranches covers direct shortcut validation branches.
|
||||
func TestShortcutValidateBranches(t *testing.T) {
|
||||
|
||||
t.Run("ImChatCreate valid", func(t *testing.T) {
|
||||
@@ -297,7 +325,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch invalid page size", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
"query": "ok",
|
||||
"page-size": "0",
|
||||
}, nil)
|
||||
@@ -307,12 +335,13 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch query too long", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": strings.Repeat("q", 65),
|
||||
t.Run("ImChatSearch allows long query for server-side notice", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
"query": strings.Repeat("q", 81),
|
||||
"page-size": "20",
|
||||
}, nil)
|
||||
err := ImChatSearch.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--query exceeds the maximum of 64 characters") {
|
||||
if err != nil {
|
||||
t.Fatalf("ImChatSearch.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -607,6 +636,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestMessagesSearchPaginationConfig verifies page-all and page-limit behavior.
|
||||
func TestMessagesSearchPaginationConfig(t *testing.T) {
|
||||
t.Run("default single page", func(t *testing.T) {
|
||||
runtime := newMessagesSearchTestRuntimeContext(t, nil, nil)
|
||||
@@ -650,8 +680,7 @@ func TestMessagesSearchPaginationConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
|
||||
// produces the expected API path, query parameters, and request body.
|
||||
// TestShortcutDryRunShapes verifies shortcut dry-run API paths and payloads.
|
||||
func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
@@ -674,19 +703,19 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch dry run includes built params", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
"query": "team-alpha",
|
||||
"page-size": "50",
|
||||
"page-token": "next_page",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":20`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
|
||||
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":50`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
|
||||
t.Fatalf("ImChatSearch.DryRun() = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
"query": "team-alpha",
|
||||
}, map[string]bool{
|
||||
"exclude-muted": true,
|
||||
|
||||
@@ -29,7 +29,7 @@ var ImChatSearch = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (max 64 chars)"},
|
||||
{Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"},
|
||||
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
|
||||
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
|
||||
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
|
||||
@@ -50,7 +50,7 @@ var ImChatSearch = common.Shortcut{
|
||||
Params(params).
|
||||
Body(body)
|
||||
},
|
||||
// Validate enforces query/member-ids presence, --query rune cap, search-types
|
||||
// Validate enforces query/member-ids presence, search-types
|
||||
// enum, --member-ids count and format, and --page-size bounds.
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
query := runtime.Str("query")
|
||||
@@ -58,9 +58,6 @@ var ImChatSearch = common.Shortcut{
|
||||
if query == "" && memberIDs == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
|
||||
}
|
||||
if query != "" && len([]rune(query)) > 64 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))).WithParam("--query")
|
||||
}
|
||||
if st := runtime.Str("search-types"); st != "" {
|
||||
allowed := map[string]struct{}{
|
||||
"private": {},
|
||||
@@ -151,6 +148,9 @@ var ImChatSearch = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
}
|
||||
if notice, _ := resData["notice"].(string); notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
if mfOut.Meta.Applied != "" {
|
||||
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ var ImMessagesSearch = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, err := searchMessages(runtime, req)
|
||||
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, notice, err := searchMessages(runtime, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -103,6 +103,9 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "No matching messages found.")
|
||||
})
|
||||
@@ -131,6 +134,9 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"page_token": nextPageToken,
|
||||
"note": "failed to fetch message details, returning ID list only",
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d messages (failed to fetch details):\n", len(messageIds))
|
||||
for _, id := range messageIds {
|
||||
@@ -206,6 +212,9 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if len(enriched) == 0 {
|
||||
fmt.Fprintln(w, "No matching messages found.")
|
||||
@@ -377,6 +386,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
}, nil
|
||||
}
|
||||
|
||||
// messagesSearchPaginationConfig derives auto-pagination mode and page limit.
|
||||
func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginate bool, pageLimit int) {
|
||||
autoPaginate = runtime.Bool("page-all")
|
||||
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
|
||||
@@ -392,7 +402,8 @@ func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginat
|
||||
return autoPaginate, pageLimit
|
||||
}
|
||||
|
||||
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, error) {
|
||||
// searchMessages fetches message search pages and returns the first server notice.
|
||||
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, string, error) {
|
||||
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
|
||||
pageToken := ""
|
||||
if tokens := req.params["page_token"]; len(tokens) > 0 {
|
||||
@@ -410,6 +421,7 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
lastPageToken string
|
||||
truncatedByLimit bool
|
||||
pageCount int
|
||||
notice string
|
||||
)
|
||||
|
||||
for {
|
||||
@@ -423,9 +435,12 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
|
||||
searchData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
|
||||
if err != nil {
|
||||
return nil, false, "", false, pageLimit, err
|
||||
return nil, false, "", false, pageLimit, "", err
|
||||
}
|
||||
|
||||
if notice == "" {
|
||||
notice, _ = searchData["notice"].(string)
|
||||
}
|
||||
items, _ := searchData["items"].([]interface{})
|
||||
allItems = append(allItems, items...)
|
||||
lastHasMore, lastPageToken = common.PaginationMeta(searchData)
|
||||
@@ -441,9 +456,10 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
pageToken = lastPageToken
|
||||
}
|
||||
|
||||
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, nil
|
||||
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, notice, nil
|
||||
}
|
||||
|
||||
// batchMGetMessages fetches message details in API-sized batches.
|
||||
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
|
||||
var items []interface{}
|
||||
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
|
||||
@@ -457,6 +473,7 @@ func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]i
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// batchQueryChatContexts fetches chat metadata best-effort for message rows.
|
||||
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
|
||||
chatContexts := map[string]map[string]interface{}{}
|
||||
// Best-effort: a failed chunk only loses its own entries.
|
||||
@@ -466,6 +483,7 @@ func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) ma
|
||||
return chatContexts
|
||||
}
|
||||
|
||||
// chunkStrings splits a string slice into fixed-size batches.
|
||||
func chunkStrings(items []string, chunkSize int) [][]string {
|
||||
if len(items) == 0 || chunkSize <= 0 {
|
||||
return nil
|
||||
|
||||
129
shortcuts/im/im_search_notice_test.go
Normal file
129
shortcuts/im/im_search_notice_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestImChatSearchExecutePassesThroughNotice verifies chat search notice output.
|
||||
func TestImChatSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
longQuery := strings.Repeat("q", 81)
|
||||
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/chats/search") {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("decode request body: %w", err)
|
||||
}
|
||||
if got, _ := body["query"].(string); got != longQuery {
|
||||
return nil, fmt.Errorf("body.query = %q, want %q", got, longQuery)
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
runtime.Cmd = newChatSearchNoticeTestCommand(t, longQuery)
|
||||
runtime.Format = "json"
|
||||
|
||||
if err := ImChatSearch.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImChatSearch.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, runtime)
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImMessagesSearchExecutePassesThroughNotice verifies message search notice output.
|
||||
func TestImMessagesSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
runtime := newMessagesSearchRuntime(t, map[string]string{
|
||||
"query": "incident",
|
||||
}, nil, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/search") {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
runtime.Format = "json"
|
||||
|
||||
if err := ImMessagesSearch.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesSearch.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, runtime)
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// newChatSearchNoticeTestCommand builds a typed chat-search command for notice tests.
|
||||
func newChatSearchNoticeTestCommand(t *testing.T, query string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for _, name := range []string{"query", "search-types", "member-ids", "sort-by", "page-token"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted"} {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("query", query); err != nil {
|
||||
t.Fatalf("Flags().Set(query) error = %v", err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// decodeShortcutData extracts the JSON envelope data object from shortcut output.
|
||||
func decodeShortcutData(t *testing.T, runtime *common.RuntimeContext) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
out, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
|
||||
if !ok {
|
||||
t.Fatalf("stdout buffer has type %T", runtime.Factory.IOStreams.Out)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, out.String())
|
||||
}
|
||||
data, ok := env["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("envelope data missing or wrong type: %#v", env)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -159,6 +159,7 @@ var MailTriage = common.Shortcut{
|
||||
var messages []map[string]interface{}
|
||||
var hasMore bool
|
||||
var nextPageToken string
|
||||
var notice string
|
||||
|
||||
useSearch, err := resolveTriagePath(parsed, query, filter)
|
||||
if err != nil {
|
||||
@@ -189,6 +190,9 @@ var MailTriage = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if notice == "" {
|
||||
notice, _ = searchData["notice"].(string)
|
||||
}
|
||||
pageMessages := buildTriageMessagesFromSearchItems(searchData["items"])
|
||||
messages = append(messages, pageMessages...)
|
||||
pageHasMore, _ := searchData["has_more"].(bool)
|
||||
@@ -282,8 +286,14 @@ var MailTriage = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
output.PrintJson(runtime.IO().Out, outData)
|
||||
default: // "table"
|
||||
if notice != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
|
||||
return nil
|
||||
@@ -760,13 +770,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["folder_id"] = folderIDFromFilter
|
||||
}
|
||||
} else {
|
||||
resolved, err := resolveFolderID(runtime, mailboxID, folderIDFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["folder_id"] = resolved
|
||||
}
|
||||
params["folder_id"] = folderIDFromFilter
|
||||
}
|
||||
} else if folderFromFilter != "" {
|
||||
if dryRun {
|
||||
@@ -776,13 +780,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["folder_id"] = folderFromFilter
|
||||
}
|
||||
} else {
|
||||
resolved, err := resolveFolderName(runtime, mailboxID, folderFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["folder_id"] = resolved
|
||||
}
|
||||
params["folder_id"] = folderFromFilter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,13 +799,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["label_id"] = labelIDFromFilter
|
||||
}
|
||||
} else {
|
||||
resolved, err := resolveLabelID(runtime, mailboxID, labelIDFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["label_id"] = resolved
|
||||
}
|
||||
params["label_id"] = labelIDFromFilter
|
||||
}
|
||||
} else if labelFromFilter != "" {
|
||||
if dryRun {
|
||||
@@ -817,13 +809,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["label_id"] = labelFromFilter
|
||||
}
|
||||
} else {
|
||||
resolved, err := resolveLabelName(runtime, mailboxID, labelFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["label_id"] = resolved
|
||||
}
|
||||
params["label_id"] = labelFromFilter
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -974,7 +975,11 @@ func TestBuildListParamsDryRunOnlyUnread(t *testing.T) {
|
||||
func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Folder: "sent"}
|
||||
got, err := buildListParams(rt, "me", f, 20, "", true)
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 20, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -983,10 +988,30 @@ func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListParamsDryRunCustomFolderPreservesInput(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Folder: "team-folder"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 20, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["folder_id"] != "team-folder" {
|
||||
t.Fatalf("expected dry-run folder_id=team-folder, got %v", got["folder_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Label: "flagged"}
|
||||
got, err := buildListParams(rt, "me", f, 10, "", true)
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 10, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -995,6 +1020,25 @@ func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListParamsDryRunCustomLabelPreservesInput(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Label: "custom-label"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 10, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := got["folder_id"]; ok {
|
||||
t.Fatalf("folder_id should not be set when label is specified, got %v", got["folder_id"])
|
||||
}
|
||||
if got["label_id"] != "custom-label" {
|
||||
t.Fatalf("expected dry-run label_id=custom-label, got %v", got["label_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildSearchParams additional coverage ---
|
||||
|
||||
func TestBuildSearchParamsAllFilterFields(t *testing.T) {
|
||||
@@ -1478,14 +1522,16 @@ func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// --- mailbox_id preservation tests ---
|
||||
|
||||
// TestMailTriageStructuredOutputPreservesMailboxID verifies mailbox and notice metadata.
|
||||
func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mailbox string
|
||||
format string
|
||||
args []string
|
||||
register func(*httpmock.Registry, string)
|
||||
wantCount int
|
||||
name string
|
||||
mailbox string
|
||||
format string
|
||||
args []string
|
||||
register func(*httpmock.Registry, string)
|
||||
wantCount int
|
||||
wantNotice string
|
||||
}{
|
||||
{
|
||||
name: "list json default mailbox",
|
||||
@@ -1522,9 +1568,10 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
register: func(reg *httpmock.Registry, mailbox string) {
|
||||
registerMailTriageSearchStub(reg, mailbox, []interface{}{
|
||||
mailTriageSearchItem("search_pub_001", "Shared search"),
|
||||
}, false, "")
|
||||
}, false, "", "The query is too long and has been truncated to the first 50 characters for search.")
|
||||
},
|
||||
wantCount: 1,
|
||||
wantCount: 1,
|
||||
wantNotice: "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
},
|
||||
{
|
||||
name: "empty list json keeps top-level mailbox",
|
||||
@@ -1559,6 +1606,9 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
if data["mailbox_id"] != tt.mailbox {
|
||||
t.Fatalf("top-level mailbox_id mismatch: got %v, want %q", data["mailbox_id"], tt.mailbox)
|
||||
}
|
||||
if tt.wantNotice != "" && data["notice"] != tt.wantNotice {
|
||||
t.Fatalf("notice mismatch: got %v, want %q", data["notice"], tt.wantNotice)
|
||||
}
|
||||
messages := mailTriageMessagesFromOutput(t, data)
|
||||
if len(messages) != tt.wantCount {
|
||||
t.Fatalf("message count mismatch: got %d, want %d", len(messages), tt.wantCount)
|
||||
@@ -1572,6 +1622,7 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageMissingMessageMetadataStillGetsMailboxID verifies fallback rows keep mailbox IDs.
|
||||
func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
@@ -1604,6 +1655,7 @@ func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageTableOutputPreservesMailboxContext verifies public mailbox table hints.
|
||||
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1654,6 +1706,33 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr verifies stderr notices.
|
||||
func TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
registerMailTriageSearchStub(reg, "me", []interface{}{
|
||||
mailTriageSearchItem("msg_search_notice", "Search notice result"),
|
||||
}, false, "", notice)
|
||||
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage",
|
||||
"--query", strings.Repeat("q", 81),
|
||||
}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if out := stdout.String(); !strings.Contains(out, "msg_search_notice") {
|
||||
t.Fatalf("stdout should contain table row, got:\n%s", out)
|
||||
}
|
||||
if errOut := stderr.String(); !strings.Contains(errOut, "notice: "+notice) {
|
||||
t.Fatalf("stderr should contain search notice, got:\n%s", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeMailTriageJSONOutput decodes structured triage output for assertions.
|
||||
func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }) map[string]interface{} {
|
||||
t.Helper()
|
||||
var data map[string]interface{}
|
||||
@@ -1663,6 +1742,7 @@ func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }
|
||||
return data
|
||||
}
|
||||
|
||||
// mailTriageMessagesFromOutput extracts triage messages as object maps.
|
||||
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
|
||||
t.Helper()
|
||||
rawMessages, ok := data["messages"].([]interface{})
|
||||
@@ -1715,7 +1795,8 @@ func registerMailTriageBatchStub(reg *httpmock.Registry, mailbox string, message
|
||||
})
|
||||
}
|
||||
|
||||
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string) {
|
||||
// registerMailTriageSearchStub registers a mailbox search response for triage tests.
|
||||
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string, notices ...string) {
|
||||
data := map[string]interface{}{
|
||||
"items": items,
|
||||
"has_more": hasMore,
|
||||
@@ -1723,6 +1804,9 @@ func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items
|
||||
if pageToken != "" {
|
||||
data["page_token"] = pageToken
|
||||
}
|
||||
if len(notices) > 0 && notices[0] != "" {
|
||||
data["notice"] = notices[0]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: mailboxPath(mailbox, "search"),
|
||||
@@ -1751,3 +1835,137 @@ func mailTriageSearchItem(messageID, subject string) map[string]interface{} {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// registerMailTriageFoldersListStub registers a NON-reusable stub for the
|
||||
// mailbox folders list API. Because it is non-reusable, any second hit returns
|
||||
// "httpmock: no stub for GET .../folders" — which is exactly the assertion we
|
||||
// use to prove resolveListFilter runs once and buildListParams does NOT
|
||||
// re-resolve. folderID/folderName is the single custom folder the API reports.
|
||||
func registerMailTriageFoldersListStub(reg *httpmock.Registry, mailbox, folderID, folderName string) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: mailboxPath(mailbox, "folders"),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": folderID,
|
||||
"name": folderName,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// registerMailTriageListPageStub registers one page of the messages list API,
|
||||
// disambiguated from sibling pages by a URL substring unique to that page
|
||||
// (e.g. "page_size=5" for page 1 vs "page_size=2" for page 2). The substring
|
||||
// must NOT depend on query-param ordering: map iteration makes param order
|
||||
// nondeterministic, so prefer a value-only token like "page_size=N" (the N
|
||||
// differs per page because pageSize = maxCount - fetched_so_far). Non-reusable
|
||||
// so reg.Verify catches under- or over-consumption.
|
||||
func registerMailTriageListPageStub(reg *httpmock.Registry, urlSubstring string, items []string, hasMore bool, pageToken string) {
|
||||
data := map[string]interface{}{
|
||||
"items": items,
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if pageToken != "" {
|
||||
data["page_token"] = pageToken
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: urlSubstring,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestMailTriageCustomFolderResolvesOnceAcrossListPages is the regression test
|
||||
// for the bug where buildListParams re-called resolveFolderID on every list
|
||||
// page, turning "resolve once" into "1 + page_count" folder-list API calls and
|
||||
// easily tripping rate limits.
|
||||
//
|
||||
// Setup: a custom folder filter that forces resolveListFilter to hit the
|
||||
// folders list API once (to map folder name "team-folder" to folder_id), then two
|
||||
// messages-list pages. The folders list stub is non-reusable, so if
|
||||
// buildListParams re-resolves, the second hit fails with "no stub". The
|
||||
// messages-list stubs are page-specific (disambiguated by page_size in the
|
||||
// URL), so both pages are served and Verify asserts each fired exactly once.
|
||||
func TestMailTriageCustomFolderResolvesOnceAcrossListPages(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
// listMailboxFolders (called once by resolveListFilter) gates on the
|
||||
// mail:user_mailbox.folder:read scope, which the default test token does
|
||||
// not carry. Re-store the token with that scope appended so the folders
|
||||
// API call is actually exercised (and thus the non-reusable folders stub
|
||||
// is the load-bearing "exactly once" assertion).
|
||||
const folderScope = "mail:user_mailbox.folder:read"
|
||||
cfg := mailTestConfig()
|
||||
if stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId); stored != nil {
|
||||
if !strings.Contains(stored.Scope, folderScope) {
|
||||
stored.Scope = stored.Scope + " " + folderScope
|
||||
if err := auth.SetStoredToken(stored); err != nil {
|
||||
t.Fatalf("re-store token with folder scope: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
mailbox = "me"
|
||||
folderName = "team-folder"
|
||||
folderID = "fld_custom_team"
|
||||
page2Token = "tok_page2"
|
||||
)
|
||||
// --max 5 with listPageMax=20 → pageSize = 5-0 = 5 on page 1, then 5-3 = 2
|
||||
// on page 2. The page_size query value disambiguates the two list stubs.
|
||||
page1IDs := []string{"msg_a", "msg_b", "msg_c"}
|
||||
page2IDs := []string{"msg_d", "msg_e"}
|
||||
|
||||
// Folders list: registered exactly once, non-reusable. Any second folder
|
||||
// lookup (the bug) fails the test with "no stub for GET .../folders".
|
||||
registerMailTriageFoldersListStub(reg, mailbox, folderID, folderName)
|
||||
// Messages list, page 1: 3 ids, has_more, hands off a page-2 token. The
|
||||
// page_size value (5 = maxCount - 0) is unique to page 1; page 2 uses 2.
|
||||
registerMailTriageListPageStub(reg, "page_size=5", page1IDs, true, page2Token)
|
||||
// Messages list, page 2: 2 ids, terminal.
|
||||
registerMailTriageListPageStub(reg, "page_size=2", page2IDs, false, "")
|
||||
// Batch metadata fetch for all 5 ids.
|
||||
registerMailTriageBatchStub(reg, mailbox, []map[string]interface{}{
|
||||
mailTriageBatchMessage("msg_a", "Subject A"),
|
||||
mailTriageBatchMessage("msg_b", "Subject B"),
|
||||
mailTriageBatchMessage("msg_c", "Subject C"),
|
||||
mailTriageBatchMessage("msg_d", "Subject D"),
|
||||
mailTriageBatchMessage("msg_e", "Subject E"),
|
||||
})
|
||||
|
||||
args := []string{
|
||||
"+triage",
|
||||
"--as", "user",
|
||||
"--mailbox", mailbox,
|
||||
"--filter", `{"folder":"` + folderName + `"}`,
|
||||
"--max", "5",
|
||||
"--format", "json",
|
||||
}
|
||||
if err := runMountedMailShortcut(t, MailTriage, args, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error running +triage (likely a second folders API call — the bug): %v", err)
|
||||
}
|
||||
|
||||
data := decodeMailTriageJSONOutput(t, stdout)
|
||||
messages := mailTriageMessagesFromOutput(t, data)
|
||||
if len(messages) != 5 {
|
||||
t.Fatalf("expected 5 messages across 2 pages, got %d (stdout=%s)", len(messages), stdout.String())
|
||||
}
|
||||
if got := data["has_more"]; got != false {
|
||||
t.Fatalf("expected has_more=false after exhausting pages, got %v", got)
|
||||
}
|
||||
// All registered stubs (1 folders + 2 list pages + 1 batch_get) are
|
||||
// non-reusable; reg.Verify (deferred above) asserts each was matched
|
||||
// exactly once. Combined with the non-reusable folders stub, this is the
|
||||
// proof that the folders list API was called exactly once across both
|
||||
// pages — the core invariant the fix restores.
|
||||
}
|
||||
|
||||
@@ -308,6 +308,9 @@ var MinutesSearch = common.Shortcut{
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) {
|
||||
if len(rows) == 0 {
|
||||
|
||||
@@ -609,6 +609,8 @@ func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
|
||||
func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -617,6 +619,7 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -641,6 +644,9 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Notice string `json:"notice"`
|
||||
} `json:"data"`
|
||||
Meta struct {
|
||||
Count int `json:"count"`
|
||||
} `json:"meta"`
|
||||
@@ -651,6 +657,9 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
if envelope.Meta.Count != 1 {
|
||||
t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count)
|
||||
}
|
||||
if envelope.Data.Notice != notice {
|
||||
t.Fatalf("data.notice = %q, want %q", envelope.Data.Notice, notice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly.
|
||||
|
||||
@@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
SlidesScreenshot,
|
||||
SlidesXMLGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,13 +204,11 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
// Build the presentation URL locally from the token. The brand-standard
|
||||
// host transparently redirects to the tenant domain (same fallback used by
|
||||
// drive +upload / wiki +node-create). This avoids the prior best-effort
|
||||
// drive metas/batch_query call, which needed an extra drive scope and 403'd
|
||||
// for users who only authorized slides scopes — without ever blocking an
|
||||
// otherwise-successful creation.
|
||||
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
// Prefer the URL returned by presentation.create. Fall back to a local
|
||||
// brand-standard URL only when the API omits it.
|
||||
if url := common.GetString(data, "url"); url != "" {
|
||||
result["url"] = url
|
||||
} else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_abc123",
|
||||
"revision_id": 1,
|
||||
"url": "https://tenant.example.com/slides/pres_abc123",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -54,10 +55,8 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
if data["title"] != "项目汇报" {
|
||||
t.Fatalf("title = %v, want 项目汇报", data["title"])
|
||||
}
|
||||
// URL is built locally from the token (brand-standard host), not fetched from
|
||||
// drive metas, so it is deterministic and needs no drive scope.
|
||||
if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
|
||||
if data["url"] != "https://tenant.example.com/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"])
|
||||
}
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode")
|
||||
@@ -647,12 +646,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
|
||||
// locally from the token — no drive metas/batch_query call is made, so creation
|
||||
// works for users who only authorized slides scopes. The httpmock registry has no
|
||||
// batch_query stub registered; if the shortcut tried to call it, the request would
|
||||
// fail the test (unregistered stub), proving the URL is built without a drive call.
|
||||
func TestSlidesCreateURLBuiltLocally(t *testing.T) {
|
||||
// TestSlidesCreateURLFallsBackToLocalBuild verifies the presentation URL is
|
||||
// constructed locally from the token when presentation.create omits url — no
|
||||
// drive metas/batch_query call is made, so creation works for users who only
|
||||
// authorized slides scopes. The httpmock registry has no batch_query stub
|
||||
// registered; if the shortcut tried to call it, the request would fail the test.
|
||||
func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
@@ -665,6 +664,7 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_local_url",
|
||||
"revision_id": 1,
|
||||
"url": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
413
shortcuts/slides/slides_replace_pages.go
Normal file
413
shortcuts/slides/slides_replace_pages.go
Normal file
@@ -0,0 +1,413 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesReplacePages rebuilds multiple pages inside an existing presentation.
|
||||
// It deliberately creates the new page before deleting the old one so a create
|
||||
// failure cannot remove existing user content. The operation is not atomic.
|
||||
const replacePagesInitialRevisionID = -1
|
||||
|
||||
var SlidesReplacePages = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+replace-pages",
|
||||
Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"},
|
||||
{Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
|
||||
return err
|
||||
}
|
||||
pages, err := parseReplacePages(runtime.Str("pages"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateReplacePagesInput(pages)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI()
|
||||
resolved, err := prepareReplacePages(runtime)
|
||||
if err != nil {
|
||||
return dry.Set("error", err.Error())
|
||||
}
|
||||
appendReplacePagesDryRunCalls(dry, resolved)
|
||||
return dry.
|
||||
Set("xml_presentation_id", resolved.PresentationID).
|
||||
Set("pages_count", len(resolved.Plan)).
|
||||
Set("plan", replacePagesPlanOutput(resolved.Plan)).
|
||||
Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
resolved, err := prepareReplacePages(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("validate-only") {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"xml_presentation_id": resolved.PresentationID,
|
||||
"pages_count": len(resolved.Plan),
|
||||
"plan": replacePagesPlanOutput(resolved.Plan),
|
||||
"status": "validated",
|
||||
"note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed",
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
revisionID := replacePagesInitialRevisionID
|
||||
results := make([]replacePageResult, 0, len(resolved.Plan))
|
||||
for i, item := range resolved.Plan {
|
||||
result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID)
|
||||
results = append(results, result)
|
||||
if result.RevisionID != nil {
|
||||
revisionID = *result.RevisionID
|
||||
}
|
||||
if err != nil {
|
||||
if runtime.Bool("continue-on-error") {
|
||||
continue
|
||||
}
|
||||
return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results)))
|
||||
}
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": resolved.PresentationID,
|
||||
"pages_count": len(resolved.Plan),
|
||||
"results": replacePageResultsOutput(results),
|
||||
"status": "completed",
|
||||
"summary": replacePagesSummaryOutput(results),
|
||||
"note": "batch replace is not atomic; each page was created before its old page was deleted",
|
||||
}
|
||||
if revisionID != replacePagesInitialRevisionID {
|
||||
out["revision_id"] = revisionID
|
||||
}
|
||||
if hasReplacePageFailures(results) {
|
||||
out["status"] = "partial_failure"
|
||||
return runtime.OutPartialFailure(out, nil)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type replacePageInput struct {
|
||||
SlideID string
|
||||
Content string
|
||||
}
|
||||
|
||||
type replacePagePlanItem struct {
|
||||
OldSlideID string
|
||||
Content string
|
||||
Locator string
|
||||
}
|
||||
|
||||
type replacePagesPrepared struct {
|
||||
PresentationID string
|
||||
Plan []replacePagePlanItem
|
||||
}
|
||||
|
||||
type replacePageResult struct {
|
||||
OldSlideID string
|
||||
NewSlideID string
|
||||
Status string
|
||||
Error string
|
||||
RevisionID *int
|
||||
}
|
||||
|
||||
func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages, err := parseReplacePages(runtime.Str("pages"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateReplacePagesInput(pages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plan, err := buildReplacePagesPlan(pages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil
|
||||
}
|
||||
|
||||
func parseReplacePages(raw string) ([]replacePageInput, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages")
|
||||
}
|
||||
var decoded []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err)
|
||||
}
|
||||
out := make([]replacePageInput, 0, len(decoded))
|
||||
for i, m := range decoded {
|
||||
p := replacePageInput{}
|
||||
if v, ok := m["slide_number"]; ok {
|
||||
_ = v
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement")
|
||||
}
|
||||
if v, ok := m["slide_id"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages")
|
||||
}
|
||||
p.SlideID = s
|
||||
}
|
||||
if v, ok := m["content"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages")
|
||||
}
|
||||
p.Content = s
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateReplacePagesInput(pages []replacePageInput) error {
|
||||
if len(pages) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
for i, p := range pages {
|
||||
id := strings.TrimSpace(p.SlideID)
|
||||
if id == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages")
|
||||
}
|
||||
if seenIDs[id] {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages")
|
||||
}
|
||||
seenIDs[id] = true
|
||||
if strings.TrimSpace(p.Content) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages")
|
||||
}
|
||||
if err := validateCompleteSlideXML(p.Content); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete <slide> XML element: %v", i, err).WithParam("--pages").WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCompleteSlideXML(content string) error {
|
||||
dec := xml.NewDecoder(strings.NewReader(content))
|
||||
depth := 0
|
||||
seenRoot := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 0 {
|
||||
if seenRoot {
|
||||
return fmt.Errorf("multiple root elements")
|
||||
}
|
||||
if t.Name.Local != "slide" {
|
||||
return fmt.Errorf("root element is <%s>, want <slide>", t.Name.Local)
|
||||
}
|
||||
seenRoot = true
|
||||
}
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
case xml.CharData:
|
||||
if depth == 0 && strings.TrimSpace(string(t)) != "" {
|
||||
return fmt.Errorf("non-whitespace text outside root element")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seenRoot {
|
||||
return fmt.Errorf("missing root element")
|
||||
}
|
||||
if depth != 0 {
|
||||
return fmt.Errorf("unclosed XML element")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) {
|
||||
plan := make([]replacePagePlanItem, 0, len(pages))
|
||||
for _, page := range pages {
|
||||
id := strings.TrimSpace(page.SlideID)
|
||||
plan = append(plan, replacePagePlanItem{
|
||||
OldSlideID: id,
|
||||
Content: page.Content,
|
||||
Locator: "slide_id",
|
||||
})
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) {
|
||||
dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)")
|
||||
for i, item := range resolved.Plan {
|
||||
dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
|
||||
Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)).
|
||||
Params(map[string]interface{}{"revision_id": "<latest_or_revision_returned_by_previous_step>"}).
|
||||
Body(map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": item.Content},
|
||||
"before_slide_id": item.OldSlideID,
|
||||
})
|
||||
dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
|
||||
Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)).
|
||||
Params(map[string]interface{}{
|
||||
"slide_id": item.OldSlideID,
|
||||
"revision_id": "<revision_returned_by_create>",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) {
|
||||
result := replacePageResult{
|
||||
OldSlideID: item.OldSlideID,
|
||||
Status: "pending",
|
||||
}
|
||||
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
|
||||
createData, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
slideURL,
|
||||
map[string]interface{}{"revision_id": revisionID},
|
||||
map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": item.Content},
|
||||
"before_slide_id": item.OldSlideID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
result.Status = "create_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
newSlideID := common.GetString(createData, "slide_id")
|
||||
if newSlideID == "" {
|
||||
err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID)
|
||||
result.Status = "create_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
result.NewSlideID = newSlideID
|
||||
if rev, ok := revisionFromData(createData); ok {
|
||||
revisionID = rev
|
||||
result.RevisionID = &rev
|
||||
}
|
||||
|
||||
deleteData, err := runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
slideURL,
|
||||
map[string]interface{}{
|
||||
"slide_id": item.OldSlideID,
|
||||
"revision_id": revisionID,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
result.Status = "delete_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
if rev, ok := revisionFromData(deleteData); ok {
|
||||
result.RevisionID = &rev
|
||||
}
|
||||
result.Status = "replaced"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func revisionFromData(data map[string]interface{}) (int, bool) {
|
||||
if _, ok := data["revision_id"]; !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(common.GetFloat(data, "revision_id")), true
|
||||
}
|
||||
|
||||
func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(plan))
|
||||
for _, item := range plan {
|
||||
out = append(out, map[string]interface{}{
|
||||
"old_slide_id": item.OldSlideID,
|
||||
"insert_before_slide_id": item.OldSlideID,
|
||||
"locator": item.Locator,
|
||||
"action": "create_before_then_delete_old",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(results))
|
||||
for _, result := range results {
|
||||
m := map[string]interface{}{
|
||||
"old_slide_id": result.OldSlideID,
|
||||
"status": result.Status,
|
||||
}
|
||||
if result.NewSlideID != "" {
|
||||
m["new_slide_id"] = result.NewSlideID
|
||||
}
|
||||
if result.Error != "" {
|
||||
m["error"] = result.Error
|
||||
}
|
||||
if result.RevisionID != nil {
|
||||
m["revision_id"] = *result.RevisionID
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} {
|
||||
replaced := countReplacedPages(results)
|
||||
return map[string]interface{}{
|
||||
"replaced": replaced,
|
||||
"failed": len(results) - replaced,
|
||||
"total": len(results),
|
||||
}
|
||||
}
|
||||
|
||||
func countReplacedPages(results []replacePageResult) int {
|
||||
n := 0
|
||||
for _, result := range results {
|
||||
if result.Status == "replaced" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func hasReplacePageFailures(results []replacePageResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Status == "create_failed" || result.Status == "delete_failed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
306
shortcuts/slides/slides_replace_pages_test.go
Normal file
306
shortcuts/slides/slides_replace_pages_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"revision_id": 12},
|
||||
},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody struct {
|
||||
Slide struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"slide"`
|
||||
BeforeSlideID string `json:"before_slide_id"`
|
||||
}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody)
|
||||
}
|
||||
if createBody.BeforeSlideID != "old2" {
|
||||
t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID)
|
||||
}
|
||||
if !strings.Contains(createBody.Slide.Content, "<slide") {
|
||||
t.Fatalf("create content = %q", createBody.Slide.Content)
|
||||
}
|
||||
deleteURL := string(deleteStub.CapturedBody)
|
||||
if deleteURL != "" {
|
||||
t.Fatalf("delete body = %q, want empty", deleteURL)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
|
||||
}
|
||||
if data["revision_id"] != float64(12) {
|
||||
t.Fatalf("revision_id = %v, want 12", data["revision_id"])
|
||||
}
|
||||
summary, _ := data["summary"].(map[string]interface{})
|
||||
if summary["failed"] != float64(0) {
|
||||
t.Fatalf("summary.failed = %v, want 0", summary["failed"])
|
||||
}
|
||||
results, _ := data["results"].([]interface{})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" {
|
||||
t.Fatalf("result = %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 3350001,
|
||||
"msg": "invalid param",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"revision_id": 12},
|
||||
},
|
||||
})
|
||||
|
||||
pages := `[
|
||||
{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"},
|
||||
{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}
|
||||
]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--continue-on-error",
|
||||
"--as", "user",
|
||||
})
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
|
||||
}
|
||||
|
||||
env := decodeReplacePagesEnvelope(t, stdout)
|
||||
if env.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
data := env.Data
|
||||
if data["status"] != "partial_failure" {
|
||||
t.Fatalf("status = %v, want partial_failure", data["status"])
|
||||
}
|
||||
summary, _ := data["summary"].(map[string]interface{})
|
||||
if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) {
|
||||
t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary)
|
||||
}
|
||||
results, _ := data["results"].([]interface{})
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results len = %d, want 2", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
second, _ := results[1].(map[string]interface{})
|
||||
if first["status"] != "create_failed" {
|
||||
t.Fatalf("first status = %v, want create_failed", first["status"])
|
||||
}
|
||||
if second["status"] != "replaced" || second["new_slide_id"] != "new2" {
|
||||
t.Fatalf("second result = %#v, want replaced with new2", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new1", "revision_id": 11},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 3350001,
|
||||
"msg": "invalid param",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
pages := `[{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--continue-on-error",
|
||||
"--as", "user",
|
||||
})
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
|
||||
}
|
||||
|
||||
env := decodeReplacePagesEnvelope(t, stdout)
|
||||
if env.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
results, _ := env.Data["results"].([]interface{})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["status"] != "delete_failed" {
|
||||
t.Fatalf("status = %v, want delete_failed", first["status"])
|
||||
}
|
||||
if first["new_slide_id"] != "new1" {
|
||||
t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesDryRunPlansOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if out["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"])
|
||||
}
|
||||
plan, _ := out["plan"].([]interface{})
|
||||
if len(plan) != 1 {
|
||||
t.Fatalf("plan len = %d, want 1", len(plan))
|
||||
}
|
||||
item, _ := plan[0].(map[string]interface{})
|
||||
if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" {
|
||||
t.Fatalf("plan item = %#v", item)
|
||||
}
|
||||
api, _ := out["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("api len = %d, want create/delete plan", len(api))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesValidationParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pages string
|
||||
}{
|
||||
{"empty pages", `[]`},
|
||||
{"slide number no longer supported", `[{"slide_number":1,"content":"<slide/>"}]`},
|
||||
{"no locator", `[{"content":"<slide/>"}]`},
|
||||
{"empty content", `[{"slide_id":"s1","content":" "}]`},
|
||||
{"not slide XML", `[{"slide_id":"s1","content":"<shape/>"}]`},
|
||||
{"duplicate id", `[{"slide_id":"s1","content":"<slide/>"},{"slide_id":"s1","content":"<slide/>"}]`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", tt.pages,
|
||||
"--as", "user",
|
||||
})
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %v, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--pages" {
|
||||
t.Fatalf("Param = %q, want --pages", ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type replacePagesEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope {
|
||||
t.Helper()
|
||||
var env replacePagesEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes()))
|
||||
}
|
||||
if env.Data == nil {
|
||||
t.Fatalf("missing data: %#v", env)
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -34,7 +34,8 @@ var SlidesScreenshot = common.Shortcut{
|
||||
Command: "+screenshot",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
// advertise/preflight its scope. Let the API fail and let callers degrade.
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
|
||||
@@ -17,11 +17,23 @@ import (
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
|
||||
t.Fatalf("user preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
|
||||
t.Fatalf("bot preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
|
||||
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
want := []string{"wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, scope := range got {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
|
||||
147
shortcuts/slides/slides_xml_get.go
Normal file
147
shortcuts/slides/slides_xml_get.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesXMLGet fetches the full XML presentation content and writes it to a
|
||||
// local file, keeping the terminal output small for large decks.
|
||||
var SlidesXMLGet = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+xml-get",
|
||||
Description: "Fetch full presentation XML and save it to a local file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
|
||||
{Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("output")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
if runtime.Int("revision-id") < -1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
presentationID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc("Fetch full presentation XML and save it to a local file")
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
dry.GET(fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)).
|
||||
Params(params)
|
||||
return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
|
||||
params,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
presentation := common.GetMap(data, "xml_presentation")
|
||||
content := common.GetString(presentation, "content")
|
||||
if content == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
|
||||
}
|
||||
outputPath := runtime.Str("output")
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(content)),
|
||||
}, bytes.NewReader([]byte(content)))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolvedPath, err := runtime.ResolveSavePath(outputPath)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"path": resolvedPath,
|
||||
"size": result.Size(),
|
||||
"content_saved": true,
|
||||
}
|
||||
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
|
||||
out["revision_id"] = int(revisionID)
|
||||
}
|
||||
if url := common.GetString(presentation, "url"); url != "" {
|
||||
out["url"] = url
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
out["remove_attr_id"] = true
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
161
shortcuts/slides/slides_xml_get_test.go
Normal file
161
shortcuts/slides/slides_xml_get_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"presentation_id": "pres_abc",
|
||||
"revision_id": 7,
|
||||
"url": "https://example.feishu.cn/slides/pres_abc",
|
||||
"content": xml,
|
||||
},
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "readback.xml",
|
||||
"--revision-id", "7",
|
||||
"--remove-attr-id",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "readback.xml")
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved XML: %v", err)
|
||||
}
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
}
|
||||
if strings.Contains(stdout.String(), xml) {
|
||||
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
|
||||
}
|
||||
if got := capturedQuery.Get("revision_id"); got != "7" {
|
||||
t.Fatalf("revision_id query = %q, want 7", got)
|
||||
}
|
||||
if got := capturedQuery.Get("remove_attr_id"); got != "true" {
|
||||
t.Fatalf("remove_attr_id query = %q, want true", got)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"])
|
||||
}
|
||||
if data["revision_id"] != float64(7) {
|
||||
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
|
||||
}
|
||||
if data["url"] != "https://example.feishu.cn/slides/pres_abc" {
|
||||
t.Fatalf("url = %v, want presentation URL", data["url"])
|
||||
}
|
||||
if data["size"] != float64(len(xml)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(xml))
|
||||
}
|
||||
gotPath, _ := data["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
t.Fatalf("path = %v, want absolute path", gotPath)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, "readback.xml") {
|
||||
t.Fatalf("path = %v, want readback.xml suffix", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "slides",
|
||||
"obj_token": "pres_real",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": `<presentation/>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
|
||||
"--output", "wiki.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_real" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "../readback.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output path error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if problem.Param != "--output" {
|
||||
t.Fatalf("param = %q, want --output", problem.Param)
|
||||
}
|
||||
}
|
||||
@@ -73,12 +73,16 @@ var SearchTask = common.Shortcut{
|
||||
var rawItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
var notice string
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if notice == "" {
|
||||
notice, _ = data["notice"].(string)
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rawItems = append(rawItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
@@ -115,6 +119,9 @@ var SearchTask = common.Shortcut{
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(enriched)}, func(w io.Writer) {
|
||||
if len(enriched) == 0 {
|
||||
fmt.Fprintln(w, "No tasks found.")
|
||||
|
||||
@@ -153,6 +153,7 @@ func TestSearchTask_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchTask_Execute verifies task search output, enrichment, and notices.
|
||||
func TestSearchTask_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -171,6 +172,7 @@ func TestSearchTask_Execute(t *testing.T) {
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
@@ -191,7 +193,7 @@ func TestSearchTask_Execute(t *testing.T) {
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`},
|
||||
wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`, `"notice": "The query is too long and has been truncated to the first 50 characters for search."`},
|
||||
},
|
||||
{
|
||||
name: "fallback to app link",
|
||||
|
||||
@@ -70,12 +70,16 @@ var SearchTasklist = common.Shortcut{
|
||||
var rawItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
var notice string
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if notice == "" {
|
||||
notice, _ = data["notice"].(string)
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rawItems = append(rawItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
@@ -118,6 +122,9 @@ var SearchTasklist = common.Shortcut{
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(tasklists)}, func(w io.Writer) {
|
||||
if len(tasklists) == 0 {
|
||||
fmt.Fprintln(w, "No tasklists found.")
|
||||
|
||||
@@ -126,6 +126,7 @@ func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchTasklist_Execute verifies tasklist search output, enrichment, and notices.
|
||||
func TestSearchTasklist_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -144,6 +145,7 @@ func TestSearchTasklist_Execute(t *testing.T) {
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-123"}},
|
||||
@@ -162,7 +164,7 @@ func TestSearchTasklist_Execute(t *testing.T) {
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`},
|
||||
wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`, `"notice": "The query is too long and has been truncated to the first 50 characters for search."`},
|
||||
},
|
||||
{
|
||||
name: "fallback on detail error",
|
||||
|
||||
@@ -236,6 +236,9 @@ var VCSearch = common.Shortcut{
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(items)}, func(w io.Writer) {
|
||||
if len(items) == 0 {
|
||||
|
||||
@@ -5,6 +5,7 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -253,6 +255,7 @@ func TestSearch_Validation_InvalidPageSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearch_DryRun verifies meeting search dry-run includes the API path.
|
||||
func TestSearch_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCSearch, []string{"+search", "--query", "test", "--dry-run", "--as", "user"}, f, stdout)
|
||||
@@ -264,6 +267,43 @@ func TestSearch_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearch_ExecutePassesThroughNotice verifies meeting search notice output.
|
||||
func TestSearch_ExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/meetings/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := mountAndRun(t, VCSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("VCSearch.Execute() error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearch_InvalidTimeRange verifies invalid meeting search time input fails.
|
||||
func TestSearch_InvalidTimeRange(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCSearch, []string{"+search", "--start", "bad-time", "--as", "user"}, f, nil)
|
||||
|
||||
@@ -204,7 +204,7 @@ func (ab *authBridge) handleLogin(w http.ResponseWriter, _ *http.Request, body [
|
||||
len(strings.Fields(scope)), req.Domains, clientID)
|
||||
|
||||
authResp, err := larkauth.RequestDeviceAuthorization(
|
||||
context.Background(), ab.httpCl, larkauth.ClientAuth{AppID: ab.appID, AppSecret: ab.appSecret}, ab.brand, scope, io.Discard,
|
||||
ab.httpCl, ab.appID, ab.appSecret, ab.brand, scope, io.Discard,
|
||||
)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadGateway, "device authorization failed: "+err.Error())
|
||||
@@ -255,7 +255,7 @@ func (ab *authBridge) handlePoll(w http.ResponseWriter, r *http.Request, body []
|
||||
}()
|
||||
|
||||
result := larkauth.PollDeviceToken(
|
||||
ctx, ab.httpCl, larkauth.ClientAuth{AppID: ab.appID, AppSecret: ab.appSecret}, ab.brand,
|
||||
ctx, ab.httpCl, ab.appID, ab.appSecret, ab.brand,
|
||||
req.DeviceCode, 5, 600, io.Discard,
|
||||
)
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
| `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` |
|
||||
| `doc` | 旧版云文档 | `drive file.comments.*` |
|
||||
| `sheet` | 电子表格 | `sheets.*` |
|
||||
| `bitable` | 多维表格 | `bitable.*` |
|
||||
| `bitable` | 多维表格 / Base | `drive file.comments.*`、`bitable.*` |
|
||||
| `slides` | 幻灯片 | `drive.*` |
|
||||
| `file` | 文件 | `drive.*` |
|
||||
| `mindnote` | 思维导图 | `drive.*` |
|
||||
@@ -112,8 +112,8 @@ Drive Folder (云空间文件夹)
|
||||
| 操作 | 需要的 Token | 说明 |
|
||||
|------|-------------|------|
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `<sheetId>!<cell>`,`slides` 使用 `<slide-block-type>!<xml-id>`;Base / bitable 只有记录局部评论,定位为 file_token(base token) + `--block-id <table-id>!<record-id>!<view-id>` |
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
|
||||
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
|
||||
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
|
||||
| 列出文档评论 | `file_token` | 同添加评论 |
|
||||
@@ -121,11 +121,15 @@ Drive Folder (云空间文件夹)
|
||||
### 评论能力边界(关键!)
|
||||
|
||||
- `drive +add-comment` 支持两种模式。
|
||||
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL。
|
||||
- 局部评论:传 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。block ID 可通过 `docs +fetch --api-version v2 --detail with-ids` 获取。
|
||||
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL。`sheet`、`slides`、Base / bitable 不支持全文评论。
|
||||
- 局部评论:传 `--block-id` 时启用;`docx` 支持文本定位或 block id,`sheet` 支持 `<sheetId>!<cell>`,`slides` 支持 `<slide-block-type>!<xml-id>`,Base / bitable 支持 `<table-id>!<record-id>!<view-id>`;wiki URL 解析到这些类型时也支持对应局部评论。Drive file 本次只支持全文评论,不支持局部评论。
|
||||
- Drive file 评论仅支持白名单扩展名:`.md`、`.txt`、`.json`、`.csv`、`.go`、`.js`、`.py`、`.pptx`、`.png`、`.jpg`、`.jpeg`、`.zip`、`.mp3`、`.mp4`。`.pdf`、`.docx`、`.xlsx` 等未在白名单内的普通文件暂不支持,CLI 会直接报错提示当前还不支持这种类型的评论。
|
||||
- Review / 审阅 / 校对 / 逐条指出问题场景优先使用局部评论,不要把多个可定位问题汇总成一条全文评论;具体参数和定位方式见生成后的 `skills/lark-drive/references/lark-drive-add-comment.md`。
|
||||
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
|
||||
- 如果 wiki 解析后不是 `doc`/`docx`,不要用 `+add-comment`。
|
||||
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API:先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。
|
||||
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`;CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。
|
||||
- Base 记录局部评论使用 `--type bitable` / `--type base` 或 `/base/`、`/bitable/`、wiki Base 链接;`bitable` 和 Base 是同一概念,`bitable` 是内部代号、Base 是产品名,裸 token 推荐传 `bitable`,`base` 仅作为兼容别名兜底。Base 不支持全局评论,所有评论都挂在记录上;定位信息必须是 file token(base token)+ `--block-id <table-id>!<record-id>!<view-id>`,其中 table/record/view ID 通常分别以 `tbl`/`rec`/`vew` 开头。view_id 只决定被提及时点击通知打开哪个视图,不影响评论挂载点,但必须传;ID 可通过 [`lark-base`](../../skills/lark-base/SKILL.md) 获取。
|
||||
- 如果 wiki 解析后不是 `doc`/`docx`/`file`/`sheet`/`slides`/`bitable`/`base`,不要用 `+add-comment`。
|
||||
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API:先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`;docx/sheet/slides 局部评论传 `anchor.block_id`,Base 记录局部评论传 `anchor.block_id`(table_id)、`anchor.base_record_id`、`anchor.base_view_id`。
|
||||
|
||||
### 评论查询与统计口径(关键!)
|
||||
|
||||
@@ -189,7 +193,7 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
|
||||
|----------|------|----------|
|
||||
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet) |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
||||
|
||||
### 授权当前应用访问文档
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-apps
|
||||
version: 1.0.0
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、可见范围时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -48,8 +48,14 @@ metadata:
|
||||
|
||||
- **发布意图判定**:用户要"可访问 / 线上 / 分享 / 新链接 / 上线" = 发布意图,先走发布链路、确认完成再给链接。
|
||||
- 完成 ≠ 发布:云端会话完成 / `+list is_published=true` 都不代表最新内容已部署。
|
||||
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}` 仅进编辑态,不能顶替发布当分享链接。
|
||||
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`:进应用编辑/开发态、管理与继续开发应用的入口。发布成功后,连同发布态链接一并提供给用户(说明"管理 / 继续开发去这里");但它仅进编辑态,**不能**顶替发布态链接当分享链接。
|
||||
- 发布态链接来源:html → `+html-publish` 的 `data.url`;全栈 → `+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`。
|
||||
- **可见范围**:发布态链接(html 的 `data.url`、全栈的 `online_url`)默认仅**创建者可见**,发给他人对方会无权限打不开。当可分享链接交付给用户前,先告知当前仅本人可见,再询问是否用 `+access-scope-set`(`tenant`/`public`/`specific`)放开(可先 `+access-scope-get` 查当前范围)。
|
||||
|
||||
## 能力边界
|
||||
|
||||
- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)/ 自动化 / 插件。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。
|
||||
- 用户要配置权限 / 自动化 / 插件时,引导其使用开发态连接前往云端开发(妙搭 web)处理。
|
||||
|
||||
## app_id 获取
|
||||
|
||||
@@ -69,4 +75,4 @@ metadata:
|
||||
## 高影响动作:确认与预授权
|
||||
|
||||
- **预授权判定**:判断用户是否表达了"放手做完、不用中途逐步问我"的意图——明确免确认(如"别问 / 直接做 / 自己定"),或要求一气呵成做到完成(如"做完部署上线给我")。是 → 整个流程按合理默认往下走、不再逐步确认(含 clone 到派生目录、发布等);否 → 缺失参数(如目录)该问就问、高影响动作先确认。
|
||||
- **不豁免底线**:会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))即便已预授权,也先 `--dry-run` 确认。
|
||||
- **禁止预授权判定底线**(即便已预授权也不豁免):① 会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))先 `--dry-run` 确认;② `+html-publish` 体积超限时(判据见 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)),立即停止并转述超限项。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 必填:`--app-id`、`--path`。
|
||||
- `--path` 可以是单个文件或目录;入口必须是 `index.html`。
|
||||
- 可选:`--allow-sensitive`,跳过凭据文件扫描。
|
||||
- 客户端会打包 tar.gz 并上传发布;压缩包上限当前为 20MB,未压缩候选文件总量也有保护上限。
|
||||
- 客户端打包 tar.gz 上传发布。三条硬性大小限制,任一超限即被客户端拒绝、无法发布:单个 `.html` 文件 ≤ 10MB、打包后 tar.gz ≤ 20MB、未压缩候选文件总量 ≤ 200MB。
|
||||
|
||||
## 示例
|
||||
|
||||
@@ -33,12 +33,19 @@ lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
|
||||
- 发布态访问链接以本命令成功返回的 `data.url` 为准。
|
||||
- 重新发布前,`+list` 的 `is_published=true` 只能说明历史上发布过,不代表当前本地产物已经部署。
|
||||
|
||||
## 发布前置门(第一步,先于任何其他动作)
|
||||
|
||||
收到发布意图后,第一个动作是量三个尺寸,不是读文件内容、不是打包:
|
||||
1. 单个 `.html` ≤ 10MB / tar.gz ≤ 20MB / 未压缩总量 ≤ 200MB。
|
||||
2. 任一超限 → 立即 STOP,把超限数字转述给用户,交还决定权。
|
||||
3. 三项都通过 → 才进入下面的命令骨架。
|
||||
|
||||
## 预览与发布边界
|
||||
|
||||
- 用户只说“用 HTML 写个 PPT/页面给我看看”时,先生成本地文件或目录,返回路径并问是否发布到妙搭分享;不要默认创建应用或部署。
|
||||
- 用户明确说“部署出去/发链接/可分享”时,才创建 `html` 应用并用 `+html-publish`。
|
||||
- 用户要发布但没有 app_id 时,先 `+create --app-type html` 创建应用;应用名可从页面/站点主题生成,不要让用户手动提供 app_id。
|
||||
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist`。`.git` 目录会被自动排除,不会进入压缩包;`node_modules`、源码缓存等仍建议手动精简以控制包体。
|
||||
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist`。`.git` 目录会被自动排除,不会进入压缩包。
|
||||
- 重新部署同一个 HTML 应用时复用原 `app_id`,只重新执行 `+html-publish --app-id <id> --path <dir-or-index.html>`。
|
||||
|
||||
## 安全规则
|
||||
@@ -48,4 +55,3 @@ lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
|
||||
## 常见失败
|
||||
|
||||
- 缺少 `index.html`:目录根放置 `index.html`,或单文件路径直接指向名为 `index.html` 的文件。
|
||||
- 包体过大:让用户精简 `--path`,不要把源码、依赖目录、构建缓存一起发布。
|
||||
|
||||
@@ -31,6 +31,7 @@ lark-cli apps +init --app-id app_xxx --dir ./my-app --dry-run
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 目标目录必须不存在、为空目录,或已含 `.spark/meta.json` 的已初始化仓库。
|
||||
- 目标目录必须不存在、为空目录,或已含 `.spark/meta.json` 且其 app_id 与 `--app-id` 一致的已初始化仓库。
|
||||
- 目标目录已含 `.spark/meta.json` 时,`+init` 会跳过 clone/scaffold,但仍执行一次 env-pull 刷新本地环境变量;告知用户“仓库已初始化,本地环境变量已刷新,可直接开发”,不要误报失败或重复 clone。
|
||||
- `+init` 输出没有必要原样复述;告诉用户 clone path、分支和下一步即可。
|
||||
- 新建应用做本地初始化时,若选定的目标目录已存在,不要复用,改用一个不冲突的目录名(已预授权”放手做”时自动追加后缀如 `-2`;否则向用户确认目录名)。
|
||||
|
||||
@@ -26,7 +26,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch --api-version v2`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
4. **需要使用 callout、grid、table、whiteboard 等富 block 时** → 参考 [`lark-doc-style.md`](references/style/lark-doc-style.md) 的元素能力说明。该文件不是固定模板或强制排版规范;除非用户明确要求美化、重排版或特定风格,不要为了“达标”主动套用固定结构。
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||
@@ -36,11 +36,9 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
- 用户需要“某个 block 的直达链接 / 锚点链接”时:返回 `文档基础 URL#block_id`。如果当前只有文档 URL 没有 block_id,先用 `docs +fetch --detail with-ids` 拿到目标 block 的 id
|
||||
- 例:
|
||||
- 已知文档 URL = `https://xxx.feishu.cn/docx/doxcn123`
|
||||
- 已知 block_id = `blkcn456`
|
||||
- 应返回 `https://xxx.feishu.cn/docx/doxcn123#blkcn456`
|
||||
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
|
||||
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
|
||||
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
>
|
||||
> **需要使用 callout、grid、table、whiteboard 等富 block,或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。该文件是表达组件参考,不是固定模板。**
|
||||
> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。**
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
@@ -74,7 +74,7 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
|
||||
## 最佳实践
|
||||
|
||||
- 文档标题从内容中自动提取:XML 使用 `<title>`;Markdown 使用文档开头唯一的一级标题(`# 标题`),正文从 `##` 开始。不要在内容开头重复写标题,也不要在 Markdown 正文中使用多个一级标题。
|
||||
- **创建较长的文档时只建骨架**:`--content` 仅传标题 + 各级 heading + 简短占位摘要;正文留给后续 `block_insert_after --block-id <章节标题 block_id>` 分段追加。一次性塞超长 `--content` 既容易触发参数限制,调试也更难。
|
||||
- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容。
|
||||
- **表达形式**:由用户目标和内容决定。需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要默认套用固定开头、固定富 block 比例或固定图表
|
||||
|
||||
## 参考
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
>
|
||||
> **需要使用 callout、grid、table、whiteboard 等富 block,或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。该文件是表达组件参考,不是固定模板。**
|
||||
> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。**
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
@@ -44,6 +44,15 @@
|
||||
| `append` | ⚠️ 在文档**末尾**追加内容(等价于 `block_insert_after --block-id -1`)。**不适用于逐章填充**——逐章写入请用 `block_insert_after` 并指定对应标题的 `--block-id` | `--content` |
|
||||
| `block_move_after` | 移动已有 block 到指定位置 | `--block-id` `--src-block-ids` |
|
||||
|
||||
## Block ID 生命周期
|
||||
|
||||
写操作后不要默认复用之前 fetch 到的 block ID:
|
||||
|
||||
- `overwrite` / `block_replace` / `block_delete`:受影响旧 ID 失效,继续 block 级操作前重新 fetch
|
||||
- `block_insert_after` / `append` / `block_copy_insert_after`:锚点 / 源 ID 通常保留,新内容是新 ID;要操作新内容先重新 fetch
|
||||
- `block_move_after`:被移动 ID 通常保留,但位置、章节、range 语义变化;后续依赖位置时重新 fetch
|
||||
- `str_replace`:简单行内替换通常不改变 ID;跨行 / 大段替换后如继续 block 级操作,先重新 fetch
|
||||
|
||||
## 指令示例
|
||||
|
||||
### str_replace — 全文文本替换
|
||||
@@ -114,8 +123,6 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace
|
||||
--content '<p>替换后的段落内容</p>'
|
||||
```
|
||||
|
||||
> `block_replace` 由服务端执行整块替换,目标 block 的 ID 不保证在替换后继续可用。后续如果还要在替换后的块附近继续 `block_insert_after`、`range` 或其他 block 级操作,先重新 `docs +fetch --detail with-ids` 获取最新 block ID,不要复用旧 ID。
|
||||
|
||||
### block_delete — 删除指定 block
|
||||
|
||||
```bash
|
||||
@@ -237,7 +244,6 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
|
||||
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
|
||||
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
|
||||
- **block_replace 后重新获取 ID**:`block_replace` 成功后旧 block ID 不保证继续可用;继续做相邻块操作前,重新 `docs +fetch --detail with-ids`
|
||||
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
|
||||
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
|
||||
1. 用 `block_insert_after` 在目标位置插入新的富文本结构
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容; |
|
||||
| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅特别复杂的图表或已有画板更新时由独立 SubAgent 读取 |
|
||||
|
||||
## 画板优先规则
|
||||
## 画板适用规则
|
||||
|
||||
写文档时,重要信息优先画板化。遇到核心流程、系统架构、方案对比、风险链路、里程碑、指标趋势、因果归因、组织关系、能力分层等内容,不要只用段落或表格承载;除非内容只是一次性补充说明,否则应规划为画板。
|
||||
写文档时,核心流程、系统架构、方案对比、风险链路、里程碑、指标趋势、因果归因、组织关系、能力分层等内容,如果图示能明显降低理解成本,可以规划为画板;结构简单或文字更清楚的内容不必强行画板化。
|
||||
|
||||
同一篇文档可以有多个画板。优先设计多个聚焦画板,而不是把所有信息塞进一张大图。
|
||||
同一篇文档可以有多个画板。确有多个独立图示点时,可拆成多个聚焦画板,而不是把所有信息塞进一张大图。
|
||||
|
||||
## 文档与画板协同流程
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
8. **优先处理步骤三识别出的画板需求**:
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
9. Spawn 内容改写 Agent 定向润色:
|
||||
- 文字密集且不易读的章节可转为 `<table>`/`<grid>`/`<callout>`,也可以拆段、改列表或保留纯文本
|
||||
- 文字密集且不易读时,优先拆段、改列表、增加小标题或调整顺序;只有确实存在行列数据、并列对比或强提醒信息时,才考虑 `<table>` / `<grid>` / `<callout>`
|
||||
- 需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用
|
||||
- 本地图片使用 `docs +media-insert` 插入
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user