mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
10 Commits
v1.0.75
...
feat/chart
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c2e3950e | ||
|
|
080fb57ad0 | ||
|
|
87aa303ca1 | ||
|
|
c8f57c659e | ||
|
|
e81e42029f | ||
|
|
e303da4b5e | ||
|
|
f6bbd86303 | ||
|
|
67fc870582 | ||
|
|
af8e027269 | ||
|
|
2efadec335 |
103
.github/workflows/release.yml
vendored
103
.github/workflows/release.yml
vendored
@@ -9,40 +9,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
|
||||
- name: Validate tag and commit
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release-preflight.js --tag "$TAG"
|
||||
git fetch origin main
|
||||
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
|
||||
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
|
||||
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-release:
|
||||
needs: preflight
|
||||
goreleaser:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -59,79 +26,35 @@ jobs:
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Include release checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s dist/checksums.txt
|
||||
(cd dist && sha256sum --check checksums.txt)
|
||||
cp dist/checksums.txt checksums.txt
|
||||
|
||||
- name: Collect release asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir npm-publish-asset
|
||||
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
|
||||
|
||||
- name: Upload release asset
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset/
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-npm:
|
||||
needs: build-release
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Download release asset
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset
|
||||
|
||||
- name: Verify npm publish asset
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(cd npm-publish-asset && sha256sum --check checksums.txt)
|
||||
cp npm-publish-asset/checksums.txt checksums.txt
|
||||
PACK_JSON="$(npm pack --ignore-scripts --json)"
|
||||
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
|
||||
test -s "$PACK_FILE"
|
||||
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
|
||||
rm "$PACK_FILE"
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
|
||||
@@ -10,10 +10,9 @@
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
|
||||
make test # Full: vet + unit + integration
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Notification Opt-Outs
|
||||
|
||||
89
CHANGELOG.md
89
CHANGELOG.md
@@ -2,92 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.75] - 2026-07-22
|
||||
|
||||
### Features
|
||||
|
||||
- add okr single create shortcut & skill text opti (#1941)
|
||||
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: improve table shortcut behavior & guidance (#1803)
|
||||
- issue#1935 & whiteboard shortcut reformat (#1980)
|
||||
- remove legacy shortcut (#1997)
|
||||
- **e2e**: inject shared credentials by identity (#1995)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: describe html5 block xml usage (#1380)
|
||||
- clarify fetch metadata and user cites (#1981)
|
||||
- add topic move collector workflow (#1473)
|
||||
- update lark doc HTML size limit (#2001)
|
||||
- **base**: align record write schema guidance (#2000)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: declare request identities explicitly (#2004)
|
||||
|
||||
### Misc
|
||||
|
||||
- harden npm release publishing (#1918)
|
||||
|
||||
## [v1.0.74] - 2026-07-21
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: add history rollback shortcuts (#1714)
|
||||
- **base**: support per-record batch updates (#1889)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- preserve slides schema issues
|
||||
- allow jq examples in quality gate dry-runs
|
||||
- **im**: warn when flag pagination is truncated (#1906)
|
||||
- **slides**: warn on text shape overflow
|
||||
- **slides**: exempt chart roundtrip attributes from lint
|
||||
- **slides**: detect image text occlusion
|
||||
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarify drive upload overwrite guidance (#1982)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate unit tests from user state (#1883)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- converge success output through a single Emitter that owns the write (#1899)
|
||||
|
||||
## [v1.0.73] - 2026-07-20
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: detect visual elements outside canvas
|
||||
- reduce public content credential fixture false positives
|
||||
- standardize CLI shortcut text in English (#1942)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: reduce filter and update retry loops (#1879)
|
||||
- **vc**: default transcript routing to smart notes over minutes (#1961)
|
||||
- clarify local trigger automation (#1958)
|
||||
|
||||
### Tests
|
||||
|
||||
- synchronize temporary Git maintenance (#1946)
|
||||
|
||||
### Misc
|
||||
|
||||
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
|
||||
- [codex] support bot menu events (#1765)
|
||||
|
||||
## [v1.0.72] - 2026-07-17
|
||||
|
||||
### Features
|
||||
@@ -1638,9 +1552,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
|
||||
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
|
||||
|
||||
9
Makefile
9
Makefile
@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
|
||||
all: test
|
||||
|
||||
@@ -51,18 +51,13 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
live-skills-test: fetch_meta
|
||||
LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
|
||||
go test -v -count=1 ./cmd/update \
|
||||
-run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
# breaks, the plugin author guide's "go build ./..." path is broken.
|
||||
examples-build:
|
||||
|
||||
@@ -344,18 +344,20 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
// Streaming formats intentionally emit each page after that page has
|
||||
// passed safety scanning. A later page may still fail, so callers
|
||||
// must use the exit code to distinguish complete vs partial output.
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type apiFailOnWriteWriter struct {
|
||||
buf bytes.Buffer
|
||||
writes int
|
||||
failAt int
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
|
||||
w.writes++
|
||||
if w.writes == w.failAt {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
previousNotice := output.PendingNotice
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, out, errOut, reg := cmdutil.TestFactory(t, config)
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
|
||||
}
|
||||
ac.ErrOut = io.Discard
|
||||
return ac, out, errOut, reg
|
||||
}
|
||||
|
||||
func apiPaginateRequest() client.RawApiRequest {
|
||||
return client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test/v1/items",
|
||||
As: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
||||
t.Helper()
|
||||
wantBytes, err := json.MarshalIndent(want, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal expected JSON: %v", err)
|
||||
}
|
||||
wantBytes = append(wantBytes, '\n')
|
||||
if !bytes.Equal(got, wantBytes) {
|
||||
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
calls := 0
|
||||
wantTokens := []string{"", "next-1", "next-2"}
|
||||
for i, wantToken := range wantTokens {
|
||||
page := i + 1
|
||||
hasMore := page < len(wantTokens)
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = wantTokens[page]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(req *http.Request) {
|
||||
calls++
|
||||
if got := req.URL.Query().Get("page_token"); got != wantToken {
|
||||
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("pagination requests = %d, want 3", calls)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1"},
|
||||
map[string]interface{}{"id": "2"},
|
||||
map[string]interface{}{"id": "3"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
})
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Carol\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if got := out.String(); got != tt.want {
|
||||
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
sentinel := errors.New("page write failed")
|
||||
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
|
||||
calls := 0
|
||||
for page := 1; page <= 2; page++ {
|
||||
hasMore := true
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": page}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = fmt.Sprintf("next-%d", page)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(*http.Request) {
|
||||
calls++
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("pagination requests = %d, want 2", calls)
|
||||
}
|
||||
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
|
||||
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
})
|
||||
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
|
||||
if got := errOut.String(); got != wantWarning {
|
||||
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
|
||||
businessResponse := map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{"detail": "business failed"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "default_json", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: businessResponse,
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
|
||||
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
|
||||
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "stream_pages", format: output.FormatNDJSON},
|
||||
{name: "default_paginate_all", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want transport error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -352,9 +352,6 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
|
||||
})
|
||||
@@ -374,33 +371,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "binary response detected") {
|
||||
t.Error("expected binary response hint in stderr")
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
|
||||
}
|
||||
savedPath, _ := got["saved_path"].(string)
|
||||
if savedPath == "" {
|
||||
t.Fatalf("saved_path missing from output: %#v", got)
|
||||
}
|
||||
// The file must land inside the temporary cwd — this pins the isolation
|
||||
// contract: rolling back TestChdir would leave download.bin in the repo.
|
||||
wantDir, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
|
||||
if err != nil {
|
||||
t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
|
||||
}
|
||||
if gotDir != wantDir {
|
||||
t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
|
||||
}
|
||||
content, err := os.ReadFile(savedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved file: %v", err)
|
||||
}
|
||||
if string(content) != "fake-binary-content" {
|
||||
t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
|
||||
if !strings.Contains(stdout.String(), "saved_path") {
|
||||
t.Error("expected saved_path in output")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates auth command tests from the host machine: config, logs
|
||||
// and the registry cache are redirected to a temp dir, then the registry is
|
||||
// seeded from the tracked fixture and initialized eagerly. Domain-completion
|
||||
// tests read the registry, so without seeding a clean checkout would either
|
||||
// fail or trigger a remote metadata fetch.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
|
||||
if err != nil {
|
||||
println("cmd/auth test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
println("cmd/auth test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
|
||||
println("cmd/auth test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -371,11 +371,10 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run",
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -707,18 +707,20 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
// Streaming formats intentionally emit each page after that page has
|
||||
// passed safety scanning. A later page may still fail, so callers
|
||||
// must use the exit code to distinguish complete vs partial output.
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type serviceFailOnWriteWriter struct {
|
||||
buf bytes.Buffer
|
||||
writes int
|
||||
failAt int
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *serviceFailOnWriteWriter) Write(p []byte) (int, error) {
|
||||
w.writes++
|
||||
if w.writes == w.failAt {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
previousNotice := output.PendingNotice
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, out, errOut, reg := cmdutil.TestFactory(t, config)
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
|
||||
}
|
||||
ac.ErrOut = io.Discard
|
||||
return ac, out, errOut, reg
|
||||
}
|
||||
|
||||
func servicePaginateRequest() client.RawApiRequest {
|
||||
return client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test/v1/items",
|
||||
As: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
||||
t.Helper()
|
||||
wantBytes, err := json.MarshalIndent(want, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal expected JSON: %v", err)
|
||||
}
|
||||
wantBytes = append(wantBytes, '\n')
|
||||
if !bytes.Equal(got, wantBytes) {
|
||||
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
calls := 0
|
||||
wantTokens := []string{"", "next-1", "next-2"}
|
||||
for i, wantToken := range wantTokens {
|
||||
page := i + 1
|
||||
hasMore := page < len(wantTokens)
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = wantTokens[page]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(req *http.Request) {
|
||||
calls++
|
||||
if got := req.URL.Query().Get("page_token"); got != wantToken {
|
||||
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("pagination requests = %d, want 3", calls)
|
||||
}
|
||||
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1"},
|
||||
map[string]interface{}{"id": "2"},
|
||||
map[string]interface{}{"id": "3"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
})
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Carol\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
if got := out.String(); got != tt.want {
|
||||
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
ac, _, errOut, reg := newServicePaginateTestHarness(t)
|
||||
sentinel := errors.New("page write failed")
|
||||
out := &serviceFailOnWriteWriter{failAt: 2, err: sentinel}
|
||||
calls := 0
|
||||
for page := 1; page <= 2; page++ {
|
||||
hasMore := true
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": page}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = fmt.Sprintf("next-%d", page)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(*http.Request) {
|
||||
calls++
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("pagination requests = %d, want 2", calls)
|
||||
}
|
||||
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
|
||||
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
})
|
||||
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
|
||||
if got := errOut.String(); got != wantWarning {
|
||||
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
|
||||
businessResponse := map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{"detail": "business failed"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "default_json", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: businessResponse,
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("servicePaginate() error = nil, want business error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
}
|
||||
assertServicePaginateJSONBytes(t, out.Bytes(), businessResponse)
|
||||
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
|
||||
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "stream_pages", format: output.FormatNDJSON},
|
||||
{name: "default_paginate_all", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, _ := newServicePaginateTestHarness(t)
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("servicePaginate() error = nil, want transport error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("servicePaginate() error = nil, want business error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates service command tests from the host machine: config (and
|
||||
// the registry cache under it) is redirected to a temp dir, then the registry
|
||||
// is seeded from the tracked fixture and initialized eagerly. Tests pass on a
|
||||
// clean checkout with no network, no `make fetch_meta`, and no user cache.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-service-test-*")
|
||||
if err != nil {
|
||||
println("cmd/service test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
|
||||
println("cmd/service test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd/service test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -13,34 +12,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
const startupBrandHelperEnv = "GO_TEST_STARTUP_BRAND_HELPER"
|
||||
|
||||
var _ = flag.String("startup-brand-helper", "", "internal startup brand test helper nonce")
|
||||
|
||||
func isStartupBrandHelper() bool {
|
||||
return startupBrandHelperEnabled(os.Getenv(startupBrandHelperEnv), startupBrandHelperNonce(os.Args))
|
||||
}
|
||||
|
||||
func startupBrandHelperEnabled(envNonce, argNonce string) bool {
|
||||
return envNonce != "" && envNonce == argNonce
|
||||
}
|
||||
|
||||
func startupBrandHelperNonce(args []string) string {
|
||||
const prefix = "-startup-brand-helper="
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
return strings.TrimPrefix(arg, prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestResolveStartupBrand_Precedence(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
@@ -78,7 +54,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
|
||||
// sync.Once, so the brand must be injected before the first catalog access.
|
||||
// It runs in a subprocess because the registry is process-global.
|
||||
func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
if isStartupBrandHelper() {
|
||||
if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
|
||||
// Helper: replicate Execute()'s build wiring with a lark config.
|
||||
buildInternal(
|
||||
context.Background(), cmdutil.InvocationContext{},
|
||||
@@ -95,11 +71,9 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
nonce := uuid.NewString()
|
||||
t.Setenv(startupBrandHelperEnv, nonce)
|
||||
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
|
||||
cmd.Args = append(cmd.Args, "-startup-brand-helper="+nonce)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GO_TEST_STARTUP_BRAND_HELPER=1",
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+tmp,
|
||||
"LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build
|
||||
)
|
||||
@@ -111,33 +85,3 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
t.Errorf("registry brand after real startup order = %s, want lark", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupBrandHelperRequiresMatchingCommandNonce(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
envNonce string
|
||||
argNonce string
|
||||
want bool
|
||||
}{
|
||||
{name: "neither set"},
|
||||
{name: "ambient environment only", envNonce: "ambient"},
|
||||
{name: "command argument only", argNonce: "command"},
|
||||
{name: "mismatch", envNonce: "ambient", argNonce: "command"},
|
||||
{name: "matching", envNonce: "nonce", argNonce: "nonce", want: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := startupBrandHelperEnabled(tt.envNonce, tt.argNonce); got != tt.want {
|
||||
t.Fatalf("startupBrandHelperEnabled() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupBrandHelperNonce(t *testing.T) {
|
||||
if got := startupBrandHelperNonce([]string{"test", "-test.run", "brand"}); got != "" {
|
||||
t.Fatalf("startupBrandHelperNonce() = %q, want empty", got)
|
||||
}
|
||||
if got := startupBrandHelperNonce([]string{"test", "-startup-brand-helper=nonce"}); got != "nonce" {
|
||||
t.Fatalf("startupBrandHelperNonce() = %q, want nonce", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates command-tree tests from the host machine: config (and the
|
||||
// registry cache under it) is redirected to a temp dir, then the registry is
|
||||
// seeded from the tracked fixture and initialized eagerly. Tests pass on a
|
||||
// clean checkout with no network, no `make fetch_meta`, and no user cache.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
if isStartupBrandHelper() {
|
||||
// Re-exec helper subprocess (startup_brand_test.go): the parent test
|
||||
// already provides an isolated config dir and disables remote metadata,
|
||||
// and the helper must own the first registry Init to prove the startup
|
||||
// order — do not seed or eagerly initialize here.
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-test-*")
|
||||
if err != nil {
|
||||
println("cmd test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
|
||||
println("cmd test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-update-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -24,8 +24,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
)
|
||||
|
||||
const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
|
||||
|
||||
// newTestFactory creates a test factory with minimal config.
|
||||
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
@@ -33,17 +31,13 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult
|
||||
// and fully mocked skills operations. Tests that only care about install-method
|
||||
// detection must never fall through to the real npx skills CLI.
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
@@ -110,18 +104,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
}
|
||||
}
|
||||
|
||||
func mockSkillsSync(t *testing.T) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -246,9 +228,6 @@ func TestNormalizeVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
mockSkillsSync(t)
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
@@ -277,9 +256,6 @@ func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
mockSkillsSync(t)
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
@@ -305,7 +281,6 @@ func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateManual_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
@@ -337,7 +312,6 @@ func TestUpdateManual_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateManual_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
@@ -1187,7 +1161,6 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1204,10 +1177,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: successfulSkillsCommand(),
|
||||
}
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
@@ -1227,7 +1197,6 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("npx failed")
|
||||
@@ -1544,133 +1513,28 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// liveSkillsIsolationEnv is the single source of truth for the user-state
|
||||
// directories a live skills test must redirect under the temporary home. It
|
||||
// covers the CLI's own config, the agent homes the skills CLI installs into,
|
||||
// the XDG dirs it derives paths from (XDG_STATE_HOME holds its global
|
||||
// .skill-lock.json), and the npm/npx overrides that take precedence over
|
||||
// HOME-derived defaults (both cases: npm reads npm_config_* case-insensitively).
|
||||
func liveSkillsIsolationEnv(home string) map[string]string {
|
||||
return map[string]string{
|
||||
"HOME": home,
|
||||
"USERPROFILE": home,
|
||||
"APPDATA": filepath.Join(home, "AppData", "Roaming"),
|
||||
"LOCALAPPDATA": filepath.Join(home, "AppData", "Local"),
|
||||
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
|
||||
"XDG_DATA_HOME": filepath.Join(home, ".local", "share"),
|
||||
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
|
||||
"CODEX_HOME": filepath.Join(home, ".codex"),
|
||||
"CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude"),
|
||||
"LARKSUITE_CLI_CONFIG_DIR": filepath.Join(home, ".lark-cli"),
|
||||
"npm_config_cache": filepath.Join(home, ".npm-cache"),
|
||||
"NPM_CONFIG_CACHE": filepath.Join(home, ".npm-cache"),
|
||||
"npm_config_prefix": filepath.Join(home, ".npm-global"),
|
||||
"NPM_CONFIG_PREFIX": filepath.Join(home, ".npm-global"),
|
||||
"npm_config_userconfig": filepath.Join(home, ".npmrc"),
|
||||
"NPM_CONFIG_USERCONFIG": filepath.Join(home, ".npmrc"),
|
||||
}
|
||||
}
|
||||
|
||||
func prepareLiveSkillsIntegration(t *testing.T) string {
|
||||
t.Helper()
|
||||
if os.Getenv(runLiveSkillsTestsEnv) != "1" {
|
||||
t.Skipf("live skills integration test disabled; set %s=1 to run", runLiveSkillsTestsEnv)
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
for key, value := range liveSkillsIsolationEnv(home) {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
func TestPrepareLiveSkillsIntegration(t *testing.T) {
|
||||
reachedAfterGate := false
|
||||
t.Run("requires explicit opt-in", func(t *testing.T) {
|
||||
t.Setenv(runLiveSkillsTestsEnv, "")
|
||||
prepareLiveSkillsIntegration(t)
|
||||
reachedAfterGate = true
|
||||
})
|
||||
if reachedAfterGate {
|
||||
t.Fatal("prepareLiveSkillsIntegration continued without explicit opt-in")
|
||||
}
|
||||
|
||||
t.Run("isolates user directories", func(t *testing.T) {
|
||||
t.Setenv(runLiveSkillsTestsEnv, "1")
|
||||
home := prepareLiveSkillsIntegration(t)
|
||||
// Pin the isolation contract by key: removing a variable from
|
||||
// liveSkillsIsolationEnv must fail this list, and every redirected
|
||||
// value must live under the temporary home.
|
||||
required := []string{
|
||||
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
|
||||
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME",
|
||||
"CODEX_HOME", "CLAUDE_CONFIG_DIR", "LARKSUITE_CLI_CONFIG_DIR",
|
||||
"npm_config_cache", "NPM_CONFIG_CACHE",
|
||||
"npm_config_prefix", "NPM_CONFIG_PREFIX",
|
||||
"npm_config_userconfig", "NPM_CONFIG_USERCONFIG",
|
||||
}
|
||||
env := liveSkillsIsolationEnv(home)
|
||||
for _, key := range required {
|
||||
expected, ok := env[key]
|
||||
if !ok {
|
||||
t.Errorf("liveSkillsIsolationEnv dropped required key %s", key)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(expected, home) {
|
||||
t.Errorf("%s = %q escapes temporary home %q", key, expected, home)
|
||||
}
|
||||
if got := os.Getenv(key); got != expected {
|
||||
t.Errorf("%s = %q, want %q", key, got, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs
|
||||
// lark-calendar into the isolated global skills dir, and returns the parsed
|
||||
// global skills list. The caller opted in explicitly, so every missing
|
||||
// precondition is a hard failure — skipping would report "nothing verified"
|
||||
// as a green run.
|
||||
func seedLiveSkillsGlobal(t *testing.T) []string {
|
||||
t.Helper()
|
||||
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
||||
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
||||
// state file. It calls the real npx skills CLI, so the test is skipped when
|
||||
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
|
||||
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Fatalf("live skills tests opted in but npx not found in PATH: %v", err)
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
}
|
||||
// Three sequential npx runs against a cold cache (the isolated home starts
|
||||
// empty) can be slow; with Fatal-on-timeout semantics the budget errs on
|
||||
// the generous side.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err)
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
|
||||
t.Fatalf("failed to seed isolated global skills: %v", err)
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("real global skills CLI unavailable: %v", err)
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if len(localSkills) == 0 {
|
||||
t.Fatal("seeded lark-calendar but global skills list is empty")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Fatalf("real skills CLI availability check timed out: %v", err)
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
return localSkills
|
||||
}
|
||||
|
||||
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
||||
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
||||
// state file. It calls the real npx skills CLI and only runs with explicit
|
||||
// opt-in. All user directories are redirected to a temporary home.
|
||||
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
prepareLiveSkillsIntegration(t)
|
||||
|
||||
// Phase 1: Verify the real npx skills CLI is available and seed the
|
||||
// isolated global skills install.
|
||||
localSkills := seedLiveSkillsGlobal(t)
|
||||
|
||||
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
|
||||
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
|
||||
@@ -1766,17 +1630,26 @@ func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
// not exist (cold start), the update command installs all official skills and
|
||||
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
|
||||
// because there is no previous state to preserve user deletions from.
|
||||
// This is a live integration test that calls the real npx skills CLI and only
|
||||
// runs with explicit opt-in. All user directories are redirected to a temporary
|
||||
// home.
|
||||
// This is a live integration test that calls the real npx skills CLI; it is
|
||||
// skipped when npx or the skills registry is unavailable.
|
||||
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
|
||||
prepareLiveSkillsIntegration(t)
|
||||
|
||||
// Phase 1: Verify the real npx skills CLI is available and seed one known
|
||||
// official skill into the isolated global install. Cold start means no
|
||||
// skills-state.json — locally installed skills may still exist, and seeding
|
||||
// one keeps the Phase 4 per-skill assertions from running zero times.
|
||||
localSkills := seedLiveSkillsGlobal(t)
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
|
||||
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
|
||||
type BotMenuOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
|
||||
TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
|
||||
EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
|
||||
MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
|
||||
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
|
||||
OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
|
||||
OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
|
||||
OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
|
||||
OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
|
||||
}
|
||||
|
||||
func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
AppID string `json:"app_id"`
|
||||
TenantKey string `json:"tenant_key"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
EventKey string `json:"event_key"`
|
||||
Timestamp json.RawMessage `json:"timestamp"`
|
||||
Operator struct {
|
||||
OperatorID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
UnionID string `json:"union_id"`
|
||||
UserID string `json:"user_id"`
|
||||
} `json:"operator_id"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
} `json:"operator"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
|
||||
timestamp := envelope.Header.CreateTime
|
||||
if timestamp == "" {
|
||||
timestamp = menuTimestamp
|
||||
}
|
||||
operatorID := envelope.Event.Operator.OperatorID.OpenID
|
||||
|
||||
out := &BotMenuOutput{
|
||||
Type: eventTypeBotMenuV6,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: timestamp,
|
||||
AppID: envelope.Header.AppID,
|
||||
TenantKey: envelope.Header.TenantKey,
|
||||
EventKey: envelope.Event.EventKey,
|
||||
MenuTimestamp: menuTimestamp,
|
||||
OperatorID: operatorID,
|
||||
OperatorOpenID: operatorID,
|
||||
OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
|
||||
OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
|
||||
OperatorName: envelope.Event.Operator.OperatorName,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func rawScalarString(raw json.RawMessage) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return text
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func timestampMillisString(raw json.RawMessage) string {
|
||||
s := rawScalarString(raw)
|
||||
if len(s) == 10 && allDigits(s) {
|
||||
return s + "000"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func allDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestKeysBotMenuMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
|
||||
}
|
||||
|
||||
def := keys[0]
|
||||
if def.Key != eventTypeBotMenuV6 {
|
||||
t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
|
||||
}
|
||||
if def.EventType != eventTypeBotMenuV6 {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
|
||||
}
|
||||
if def.SubscriptionType != "" {
|
||||
t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Fatal("Schema.Custom is nil")
|
||||
}
|
||||
if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
|
||||
t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Fatal("Schema.Native must be nil for processed output")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Fatal("Process is nil")
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
|
||||
t.Errorf("AuthTypes = %#v", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotMenuRegistersCleanly(t *testing.T) {
|
||||
const key = eventTypeBotMenuV6
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenu(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_001",
|
||||
"event_type": "application.bot.menu_v6",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test",
|
||||
"tenant_key": "tenant_test"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": 1776409469000,
|
||||
"operator": {
|
||||
"operator_id": {
|
||||
"open_id": "ou_operator",
|
||||
"union_id": "on_operator",
|
||||
"user_id": "user_operator"
|
||||
},
|
||||
"operator_name": "Test User"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Type != eventTypeBotMenuV6 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
|
||||
}
|
||||
if out.EventID != "ev_menu_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
if out.EventKey != "start_eval" {
|
||||
t.Errorf("EventKey = %q", out.EventKey)
|
||||
}
|
||||
if out.MenuTimestamp != "1776409469000" {
|
||||
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
|
||||
}
|
||||
if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
|
||||
t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
|
||||
}
|
||||
if out.OperatorUnionID != "on_operator" {
|
||||
t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
|
||||
}
|
||||
if out.OperatorUserID != "user_operator" {
|
||||
t.Errorf("OperatorUserID = %q", out.OperatorUserID)
|
||||
}
|
||||
if out.OperatorName != "Test User" {
|
||||
t.Errorf("OperatorName = %q", out.OperatorName)
|
||||
}
|
||||
if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
|
||||
t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_002",
|
||||
"event_type": "application.bot.menu_v6"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": "1776409469001",
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Timestamp != "1776409469001" {
|
||||
t.Errorf("Timestamp fallback = %q", out.Timestamp)
|
||||
}
|
||||
if out.MenuTimestamp != "1776409469001" {
|
||||
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_seconds",
|
||||
"event_type": "application.bot.menu_v6"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": 1694592375,
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Timestamp != "1694592375000" {
|
||||
t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
|
||||
}
|
||||
if out.MenuTimestamp != "1694592375000" {
|
||||
t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_003",
|
||||
"event_type": "unexpected.event_type",
|
||||
"create_time": "1776409469275"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Type != eventTypeBotMenuV6 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuMalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processBotMenu(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func runBotMenu(t *testing.T, payload string) BotMenuOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processBotMenu(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("processBotMenu: %v", err)
|
||||
}
|
||||
var out BotMenuOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\n%s", err, got)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package application registers Application-domain EventKeys.
|
||||
package application
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const eventTypeBotMenuV6 = "application.bot.menu_v6"
|
||||
|
||||
// Keys returns all Application-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeBotMenuV6,
|
||||
DisplayName: "Bot menu",
|
||||
Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
|
||||
},
|
||||
Process: processBotMenu,
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeBotMenuV6},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/application"
|
||||
"github.com/larksuite/cli/events/approval"
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
application.Keys(),
|
||||
approval.Keys(),
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-internal-auth-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -132,14 +132,16 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
})
|
||||
}
|
||||
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: opts.Out,
|
||||
ErrOut: opts.ErrOut,
|
||||
CommandPath: opts.CommandPath,
|
||||
Identity: string(identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
return emitter.Success(result, output.EmitOptions{Format: opts.Format.String()})
|
||||
// Content safety scanning for non-JSON presentation formats.
|
||||
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
|
||||
}
|
||||
output.FormatValue(opts.Out, result, opts.Format)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-JSON (binary) responses.
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
@@ -240,87 +239,6 @@ func TestHandleResponse_JSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_NonJSONFormatsEmitExactStructuredResponseBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Bob\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Bob \n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Bob\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
map[string]interface{}{"id": "2", "name": "Bob"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
httpResp, err := httpmock.NewClient(reg).Get("https://open.feishu.cn/open-apis/test/v1/items")
|
||||
if err != nil {
|
||||
t.Fatalf("fixture request failed: %v", err)
|
||||
}
|
||||
body, err := io.ReadAll(httpResp.Body)
|
||||
_ = httpResp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture response: %v", err)
|
||||
}
|
||||
resp := &larkcore.ApiResp{
|
||||
StatusCode: httpResp.StatusCode,
|
||||
Header: httpResp.Header.Clone(),
|
||||
RawBody: body,
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err = HandleResponse(resp, ResponseOptions{
|
||||
Format: tt.format,
|
||||
Identity: core.AsBot,
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
CommandPath: "lark-cli api GET",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse() error = %v, want nil", err)
|
||||
}
|
||||
if got := out.String(); got != tt.want {
|
||||
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
reg.Verify(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) {
|
||||
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
|
||||
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
@@ -14,12 +18,75 @@ import (
|
||||
// with --yes.
|
||||
//
|
||||
// action identifies the operation for the agent (e.g. "mail +send",
|
||||
// "drive.files.delete"). The envelope does not carry a pre-built retry
|
||||
// command: agents already know their original invocation and only need to
|
||||
// append --yes per the hint, which keeps the protocol free of shell-quoting
|
||||
// pitfalls.
|
||||
// "drive.files.delete"). When the original invocation can be re-run safely,
|
||||
// the hint carries the complete retry command with --yes appended — eval
|
||||
// traces show agents always self-heal by appending --yes, so handing them
|
||||
// the exact line saves the reconstruction step. The retry line is omitted
|
||||
// (falling back to the plain hint) when any argument reads stdin (a bare "-",
|
||||
// as its own token or bundled onto a flag as --flag=-, whose piped data a
|
||||
// bare re-run would not reproduce) or when the rendered command would be
|
||||
// unreasonably long to echo back.
|
||||
func RequireConfirmation(action string) error {
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action).
|
||||
WithHint("add --yes to confirm")
|
||||
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
|
||||
"%s requires confirmation", action)
|
||||
if retry := retryCommandWithYes(os.Args); retry != "" {
|
||||
return err.WithHint("add --yes to confirm; re-run: %s", retry)
|
||||
}
|
||||
return err.WithHint("add --yes to confirm")
|
||||
}
|
||||
|
||||
// retryCommandMaxLen caps the rendered retry command: past this, echoing the
|
||||
// full invocation back (e.g. a +batch-update with a large inline JSON)
|
||||
// costs more context than it saves.
|
||||
const retryCommandMaxLen = 300
|
||||
|
||||
// retryCommandWithYes renders args as a shell-safe command line with --yes
|
||||
// appended, or "" when a safe rendering isn't possible (see
|
||||
// RequireConfirmation).
|
||||
func retryCommandWithYes(args []string) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(args)+1)
|
||||
parts = append(parts, filepath.Base(args[0]))
|
||||
for _, a := range args[1:] {
|
||||
if argReadsStdin(a) {
|
||||
return ""
|
||||
}
|
||||
parts = append(parts, shellQuoteArg(a))
|
||||
}
|
||||
parts = append(parts, "--yes")
|
||||
line := strings.Join(parts, " ")
|
||||
if len(line) > retryCommandMaxLen {
|
||||
return ""
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// argReadsStdin reports whether an argument makes a flag read from stdin — the
|
||||
// portable bare "-" value, whether passed as its own token (--flag -) or
|
||||
// bundled onto the flag (--flag=- / -f=-). Piped stdin is one-shot data a bare
|
||||
// re-run cannot reproduce, so any such argument suppresses the retry line.
|
||||
func argReadsStdin(a string) bool {
|
||||
if a == "-" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(a, "-") {
|
||||
if i := strings.IndexByte(a, '='); i >= 0 && a[i+1:] == "-" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shellQuoteArg single-quotes an argument when it contains any character a
|
||||
// POSIX shell could interpret, so the retry line is copy-paste safe.
|
||||
func shellQuoteArg(s string) string {
|
||||
if s == "" {
|
||||
return "''"
|
||||
}
|
||||
if !strings.ContainsAny(s, " \t\n\"'\\$`!*?[](){}<>|&;#~") {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
@@ -35,8 +35,11 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
|
||||
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
|
||||
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
|
||||
}
|
||||
if cre.Hint != "add --yes to confirm" {
|
||||
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
|
||||
// The hint may additionally carry a re-run line composed from the live
|
||||
// os.Args (environment-dependent under `go test`), but the add-yes
|
||||
// contract always leads.
|
||||
if !strings.HasPrefix(cre.Hint, "add --yes to confirm") {
|
||||
t.Errorf("Hint = %q, want prefix 'add --yes to confirm'", cre.Hint)
|
||||
}
|
||||
if cre.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
|
||||
@@ -61,8 +64,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// No fix_command field leaks into the envelope: the protocol avoids
|
||||
// shell-quoting hazards by delegating retry to agent-side logic.
|
||||
// No fix_command field leaks into the envelope: the retry line lives in
|
||||
// the free-text hint only; the typed protocol stays action-only.
|
||||
if _, has := back["fix_command"]; has {
|
||||
t.Errorf("unexpected fix_command present in JSON: %s", raw)
|
||||
}
|
||||
@@ -78,3 +81,46 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
|
||||
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryCommandWithYes pins the retry-line contract: shell-safe quoting,
|
||||
// basename argv[0], and the two omission guards (stdin args, oversized
|
||||
// commands).
|
||||
func TestRetryCommandWithYes(t *testing.T) {
|
||||
t.Run("quotes what needs quoting and appends --yes", func(t *testing.T) {
|
||||
got := retryCommandWithYes([]string{
|
||||
"/usr/local/bin/lark-cli", "sheets", "+cells-clear",
|
||||
"--url", "https://x.feishu.cn/sheets/tok",
|
||||
"--range", "A1:B2", "--sheet-name", "第 1 班",
|
||||
})
|
||||
want := `lark-cli sheets +cells-clear --url https://x.feishu.cn/sheets/tok --range A1:B2 --sheet-name '第 1 班' --yes`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single quotes inside args survive", func(t *testing.T) {
|
||||
got := retryCommandWithYes([]string{"lark-cli", "x", "--title", "it's"})
|
||||
if !strings.Contains(got, `'it'\''s'`) {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stdin arg omits the retry line", func(t *testing.T) {
|
||||
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+batch-update", "--operations", "-"}); got != "" {
|
||||
t.Errorf("stdin invocation must not render a retry line, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bundled stdin flag omits the retry line", func(t *testing.T) {
|
||||
// --flag=- reads stdin the same as --flag -; both must suppress the line.
|
||||
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+cells-set", "--cells=-"}); got != "" {
|
||||
t.Errorf("--flag=- stdin invocation must not render a retry line, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized command omits the retry line", func(t *testing.T) {
|
||||
if got := retryCommandWithYes([]string{"lark-cli", "x", "--operations", strings.Repeat("a", 400)}); got != "" {
|
||||
t.Errorf("oversized invocation must not render a retry line, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Default-factory tests initialize the registry and resolve config. Keep
|
||||
// them deterministic: never read the developer's real ~/.lark-cli and
|
||||
// prevent background remote-metadata refreshes from touching user state.
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmdutil-test-*")
|
||||
if err != nil {
|
||||
println("internal/cmdutil test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-event-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-keychain-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for key, value := range map[string]string{
|
||||
"LARKSUITE_CLI_DATA_DIR": filepath.Join(root, "data"),
|
||||
"LARKSUITE_CLI_LOG_DIR": filepath.Join(root, "logs"),
|
||||
} {
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -7,91 +7,70 @@ import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// FormatAsCSV formats data as CSV (with header) and writes it to w.
|
||||
func FormatAsCSV(w io.Writer, data interface{}) {
|
||||
// Match the other legacy wrappers: surface only a marshal failure (as the
|
||||
// JSON fallback historically did); plain write failures stay swallowed.
|
||||
if err := WriteCSV(w, data); isOutputMarshalError(err) {
|
||||
legacyStderrf("json marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteCSV formats data as CSV and returns marshal or write errors.
|
||||
func WriteCSV(w io.Writer, data interface{}) error {
|
||||
return WriteCSVPaginated(w, data, true)
|
||||
FormatAsCSVPaginated(w, data, true)
|
||||
}
|
||||
|
||||
// FormatAsCSVPaginated formats data as CSV with pagination awareness.
|
||||
// When isFirstPage is true, outputs the header row; otherwise only data rows.
|
||||
func FormatAsCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) {
|
||||
if err := WriteCSVPaginated(w, data, isFirstPage); isOutputMarshalError(err) {
|
||||
legacyStderrf("json marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteCSVPaginated formats data as CSV and returns marshal or write errors.
|
||||
func WriteCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) error {
|
||||
rows, cols, isList := prepareRows(data)
|
||||
if cols == nil {
|
||||
if isList {
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
} else {
|
||||
return WriteJSON(w, data)
|
||||
PrintJson(w, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if isFirstPage {
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
}
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
if !isList {
|
||||
// Single object: key,value rows
|
||||
cw := csv.NewWriter(w)
|
||||
if isFirstPage {
|
||||
if err := cw.Write([]string{"key", "value"}); err != nil {
|
||||
return err
|
||||
}
|
||||
cw.Write([]string{"key", "value"})
|
||||
}
|
||||
for _, col := range cols {
|
||||
if err := cw.Write([]string{col, rows[0][col]}); err != nil {
|
||||
return err
|
||||
}
|
||||
cw.Write([]string{col, rows[0][col]})
|
||||
}
|
||||
return flushCSV(cw)
|
||||
flushCSV(cw)
|
||||
return
|
||||
}
|
||||
|
||||
return writeCSVRows(w, rows, cols, isFirstPage)
|
||||
writeCSVRows(w, rows, cols, isFirstPage)
|
||||
}
|
||||
|
||||
// writeCSVRows writes CSV data rows (and optionally header) using the given columns.
|
||||
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) error {
|
||||
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) {
|
||||
cw := csv.NewWriter(w)
|
||||
if writeHeader {
|
||||
if err := cw.Write(cols); err != nil {
|
||||
return err
|
||||
}
|
||||
cw.Write(cols)
|
||||
}
|
||||
for _, row := range rows {
|
||||
record := make([]string, len(cols))
|
||||
for i, col := range cols {
|
||||
record[i] = row[col]
|
||||
}
|
||||
if err := cw.Write(record); err != nil {
|
||||
return err
|
||||
}
|
||||
cw.Write(record)
|
||||
}
|
||||
return flushCSV(cw)
|
||||
flushCSV(cw)
|
||||
}
|
||||
|
||||
// flushCSV flushes the csv.Writer and returns any write error.
|
||||
func flushCSV(cw *csv.Writer) error {
|
||||
// flushCSV flushes the csv.Writer and reports any write error to stderr.
|
||||
func flushCSV(cw *csv.Writer) {
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
if err := cw.Error(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "csv write error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,10 @@ func wrapBlockError(alert *extcs.Alert) error {
|
||||
|
||||
// WriteAlertWarning writes a human-readable content-safety warning to w.
|
||||
// Used by non-JSON output paths (pretty, table, csv) in warn mode.
|
||||
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
|
||||
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) {
|
||||
if alert == nil {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
|
||||
fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
|
||||
alert.Provider, strings.Join(alert.MatchedRules, ", "))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// NoticeProvider supplies the notice attached to a structured envelope.
|
||||
// The provider is captured by an Emitter so emission never reads the global
|
||||
// PendingNotice hook implicitly.
|
||||
type NoticeProvider func() map[string]interface{}
|
||||
|
||||
// PrettyRenderer writes the human-readable representation of one result.
|
||||
// colorEnabled is the terminal capability captured when the Emitter is built.
|
||||
type PrettyRenderer func(w io.Writer, colorEnabled bool) error
|
||||
|
||||
// EmitterConfig contains command-scoped dependencies. A command constructs one
|
||||
// Emitter and reuses it for its success result or streamed pages.
|
||||
type EmitterConfig struct {
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
CommandPath string
|
||||
Identity string
|
||||
ColorEnabled bool
|
||||
NoticeProvider NoticeProvider
|
||||
}
|
||||
|
||||
// EmitOptions describes one result's wire representation.
|
||||
//
|
||||
// The format contract is explicit: JSON (including the empty default) uses an
|
||||
// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes
|
||||
// precedence over Format and filters the JSON Envelope. Raw affects only JSON
|
||||
// envelope encoding and jq's complex-value encoding.
|
||||
//
|
||||
// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit
|
||||
// (false) and WriteSuccessEnvelope (true) until their callers are migrated.
|
||||
type EmitOptions struct {
|
||||
Raw bool
|
||||
Meta *Meta
|
||||
Format string
|
||||
JQ string
|
||||
DryRun bool
|
||||
Pretty PrettyRenderer
|
||||
JQSafetyWarning bool
|
||||
}
|
||||
|
||||
// StreamOptions describes one streamed page's wire representation. Streaming
|
||||
// carries page items directly, so it deliberately exposes only the fields that
|
||||
// affect a single page: the format and, for pretty, its renderer. It has no
|
||||
// OK/Meta/DryRun/JQ — an ok:false envelope, metadata, dry-run, and jq all need
|
||||
// the aggregated result, which the caller's pagination layer owns before it
|
||||
// streams pages.
|
||||
type StreamOptions struct {
|
||||
Format string
|
||||
Pretty PrettyRenderer
|
||||
}
|
||||
|
||||
// Emitter owns all command-scoped output dependencies and pagination state.
|
||||
// It deliberately has no dependency on client or cmdutil.
|
||||
type Emitter struct {
|
||||
out io.Writer
|
||||
errOut io.Writer
|
||||
commandPath string
|
||||
identity string
|
||||
colorEnabled bool
|
||||
noticeProvider NoticeProvider
|
||||
|
||||
streamFormat string
|
||||
streamFormatter *PaginatedFormatter
|
||||
}
|
||||
|
||||
// NewEmitter constructs a command-scoped output emitter.
|
||||
func NewEmitter(config EmitterConfig) *Emitter {
|
||||
errOut := config.ErrOut
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
return &Emitter{
|
||||
out: config.Out,
|
||||
errOut: errOut,
|
||||
commandPath: config.CommandPath,
|
||||
identity: config.Identity,
|
||||
colorEnabled: config.ColorEnabled,
|
||||
noticeProvider: config.NoticeProvider,
|
||||
}
|
||||
}
|
||||
|
||||
// Success scans and emits one command result by composing the package's leaf
|
||||
// primitives. JSON and jq use the standard envelope; pretty, table, csv, and
|
||||
// ndjson render the business value directly.
|
||||
func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
|
||||
if err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.JQ != "" {
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
|
||||
switch opts.Format {
|
||||
case "", "json":
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
case "pretty":
|
||||
return e.emitPretty(data, opts)
|
||||
default:
|
||||
return e.emitFormatted(data, opts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
// PartialFailure emits a multi-status result whose envelope honestly reports
|
||||
// ok:false. It is the typed counterpart to Success for batch operations where
|
||||
// some items failed but the per-item outcomes are the primary stdout output.
|
||||
// Like the legacy OutPartialFailure it produces only the JSON/jq envelope; the
|
||||
// caller owns the non-zero exit signal, keeping the Emitter free of exit
|
||||
// semantics.
|
||||
func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
|
||||
if err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.emitEnvelope(data, false, opts)
|
||||
}
|
||||
|
||||
// StreamPage scans and emits one page while retaining table/csv columns from
|
||||
// the first page. Streamed output carries page items directly, so it takes a
|
||||
// StreamOptions (format + optional pretty renderer) rather than the full
|
||||
// EmitOptions: ok/meta/dry-run/jq all need the aggregated result and are the
|
||||
// caller's pagination-layer responsibility, not a per-page concern. Excluding
|
||||
// jq from the type makes "jq requires aggregated output" a compile-time fact
|
||||
// instead of a runtime rejection.
|
||||
func (e *Emitter) StreamPage(data interface{}, opts StreamOptions) error {
|
||||
if err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
if opts.Pretty == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pretty output requires a renderer")
|
||||
}
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return opts.Pretty(w, e.colorEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
format, known := ParseFormat(opts.Format)
|
||||
if !known && e.streamFormatter == nil && e.errOut != nil {
|
||||
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
if e.streamFormatter == nil {
|
||||
e.streamFormat = opts.Format
|
||||
e.streamFormatter = NewPaginatedFormatter(nil, format)
|
||||
} else if opts.Format != e.streamFormat {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"stream output format changed from %q to %q", e.streamFormat, opts.Format)
|
||||
}
|
||||
|
||||
return e.emit(func(w io.Writer) error {
|
||||
e.streamFormatter.W = w
|
||||
return e.streamFormatter.WritePage(data)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) error {
|
||||
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
|
||||
env := Envelope{
|
||||
OK: ok,
|
||||
Identity: e.identity,
|
||||
DryRun: opts.DryRun,
|
||||
Data: data,
|
||||
Meta: opts.Meta,
|
||||
Notice: e.notice(),
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
env.ContentSafetyAlert = scanResult.Alert
|
||||
}
|
||||
|
||||
if opts.JQ != "" {
|
||||
if scanResult.Alert != nil && opts.JQSafetyWarning {
|
||||
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
}
|
||||
// Buffer the jq output manually so jq's own typed error (a validation
|
||||
// error for a bad expression, an api error for a runtime failure) is
|
||||
// returned unchanged; only a genuine stdout write failure is wrapped as
|
||||
// an internal output error.
|
||||
var buf bytes.Buffer
|
||||
var jqErr error
|
||||
if opts.Raw {
|
||||
jqErr = JqFilterRaw(&buf, env, opts.JQ)
|
||||
} else {
|
||||
jqErr = JqFilter(&buf, env, opts.JQ)
|
||||
}
|
||||
if jqErr != nil {
|
||||
return jqErr
|
||||
}
|
||||
if _, err := io.Copy(e.out, &buf); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.emit(func(w io.Writer) error {
|
||||
if opts.Raw {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(env)
|
||||
}
|
||||
return WriteJSON(w, env)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
|
||||
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
}
|
||||
if opts.Pretty != nil {
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return opts.Pretty(w, e.colorEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
// RuntimeContext.outFormat falls back through Out/OutRaw when no pretty
|
||||
// renderer is supplied. Keep that second scan visible in the leaf contract
|
||||
// until production callers are migrated and the legacy behavior is removed.
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
|
||||
func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
|
||||
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
}
|
||||
|
||||
format, known := ParseFormat(rawFormat)
|
||||
if !known && e.errOut != nil {
|
||||
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat)
|
||||
}
|
||||
if format == FormatJSON {
|
||||
return e.printLegacyDataJSON(data)
|
||||
}
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteFormatted(w, data, format)
|
||||
})
|
||||
}
|
||||
|
||||
type emitterDataMap map[string]interface{}
|
||||
|
||||
// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice
|
||||
// data from this Emitter instead of PrintJson's global PendingNotice hook.
|
||||
func (e *Emitter) printLegacyDataJSON(data interface{}) error {
|
||||
// Normalise structs / named maps to plain generic types first, exactly as
|
||||
// FormatValue does, so a struct or named-map payload still matches the map
|
||||
// case below and keeps its injected _notice on the unknown-format fallback.
|
||||
data = toGeneric(data)
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
if _, isEnvelope := m["ok"]; isEnvelope {
|
||||
if notice := e.notice(); notice != nil {
|
||||
m = maps.Clone(m)
|
||||
m["_notice"] = notice
|
||||
}
|
||||
}
|
||||
// The named map retains identical JSON bytes while preventing PrintJson
|
||||
// from consulting its legacy global notice hook a second time.
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteJSON(w, emitterDataMap(m))
|
||||
})
|
||||
}
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteJSON(w, data)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Emitter) emit(render func(io.Writer) error) error {
|
||||
var buf bytes.Buffer
|
||||
if err := render(&buf); err != nil {
|
||||
return wrapOutputError("render", err)
|
||||
}
|
||||
if _, err := io.Copy(e.out, &buf); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wrapOutputError(op string, err error) error {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s command output", op).WithCause(err)
|
||||
}
|
||||
|
||||
func (e *Emitter) notice() map[string]interface{} {
|
||||
if e.noticeProvider == nil {
|
||||
return nil
|
||||
}
|
||||
return e.noticeProvider()
|
||||
}
|
||||
|
||||
func (e *Emitter) requireOutput() error {
|
||||
if e == nil || e.out == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"success output writer is not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type contractFailingWriter struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (w contractFailingWriter) Write([]byte) (int, error) {
|
||||
return 0, w.err
|
||||
}
|
||||
|
||||
type contractSafetyProvider struct {
|
||||
alert *extcs.Alert
|
||||
}
|
||||
|
||||
func (p *contractSafetyProvider) Name() string {
|
||||
return "emitter-contract"
|
||||
}
|
||||
|
||||
func (p *contractSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.alert, nil
|
||||
}
|
||||
|
||||
func TestEmitterSuccessWritesAllBytes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
})
|
||||
data := map[string]interface{}{"id": "1"}
|
||||
|
||||
err := emitter.Success(data, output.EmitOptions{Format: "json"})
|
||||
if err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
want, marshalErr := json.MarshalIndent(output.Envelope{OK: true, Identity: "bot", Data: data}, "", " ")
|
||||
if marshalErr != nil {
|
||||
t.Fatalf("marshal expected envelope: %v", marshalErr)
|
||||
}
|
||||
want = append(want, '\n')
|
||||
if !bytes.Equal(stdout.Bytes(), want) {
|
||||
t.Fatalf("stdout bytes = %q, want %q", stdout.Bytes(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterMarshalFailureReturnsTypedErrorWithoutOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
|
||||
err := emitter.Success(map[string]interface{}{"unsupported": func() {}}, output.EmitOptions{Format: "json"})
|
||||
if err == nil {
|
||||
t.Fatal("Emitter.Success() error = nil, want marshal failure")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
var unsupported *json.UnsupportedTypeError
|
||||
if !errors.As(err, &unsupported) {
|
||||
t.Fatalf("Emitter.Success() error = %v, want json.UnsupportedTypeError cause", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterWriterFailurePreservesCause(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
sentinel := errors.New("write failed")
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: contractFailingWriter{err: sentinel},
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterPrettyRendererFailurePreservesCause(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
sentinel := errors.New("pretty render failed")
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
|
||||
Format: "pretty",
|
||||
Pretty: func(io.Writer, bool) error {
|
||||
return sentinel
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("Emitter.Success() error = %v, want preserved renderer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterAlertWarningFailurePreservesCause(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&contractSafetyProvider{alert: &extcs.Alert{
|
||||
Provider: "emitter-contract",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
}})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
sentinel := errors.New("warning write failed")
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: contractFailingWriter{err: sentinel},
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
|
||||
err := emitter.Success([]interface{}{map[string]interface{}{"id": "1"}}, output.EmitOptions{Format: "table"})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("Emitter.Success() error = %v, want preserved warning writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEmitterDefaultsNilErrOutToDiscard(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&contractSafetyProvider{alert: &extcs.Alert{
|
||||
Provider: "emitter-contract",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
}})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
|
||||
if err := emitter.Success([]interface{}{map[string]interface{}{"id": "1"}}, output.EmitOptions{Format: "table"}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
t.Fatal("Emitter.Success() stdout is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterDoesNotMutateCallerMap(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
data := map[string]interface{}{"ok": true, "value": "fixture"}
|
||||
want := map[string]interface{}{"ok": true, "value": "fixture"}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
NoticeProvider: func() map[string]interface{} {
|
||||
return map[string]interface{}{"update": "available"}
|
||||
},
|
||||
})
|
||||
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: "yaml"}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(data, want) {
|
||||
t.Fatalf("caller map = %#v, want unchanged %#v", data, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterDoesNotOverwriteCallerNotice(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
existing := map[string]interface{}{"source": "caller"}
|
||||
data := map[string]interface{}{"ok": true, "_notice": existing}
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
NoticeProvider: func() map[string]interface{} {
|
||||
return map[string]interface{}{"source": "provider"}
|
||||
},
|
||||
})
|
||||
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: "yaml"}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if got := data["_notice"]; !reflect.DeepEqual(got, existing) {
|
||||
t.Fatalf("caller _notice = %#v, want unchanged %#v", got, existing)
|
||||
}
|
||||
var emitted map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &emitted); err != nil {
|
||||
t.Fatalf("decode stdout: %v", err)
|
||||
}
|
||||
if got := emitted["_notice"]; !reflect.DeepEqual(got, map[string]interface{}{"source": "provider"}) {
|
||||
t.Fatalf("emitted _notice = %#v, want provider notice", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterReadsNoticeProviderAtMostOncePerEmission(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
calls := 0
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
NoticeProvider: func() map[string]interface{} {
|
||||
calls++
|
||||
return map[string]interface{}{"source": "provider"}
|
||||
},
|
||||
})
|
||||
|
||||
if err := emitter.Success(map[string]interface{}{"ok": true}, output.EmitOptions{Format: "yaml"}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("notice provider calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterRawJSONPropagatesWriteError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
sentinel := errors.New("write failed")
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: contractFailingWriter{err: sentinel},
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
|
||||
Raw: true, Format: "json",
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterInvalidJQReturnsErrorWithoutStderr(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: stderr,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
|
||||
Format: "json",
|
||||
JQ: "this is not valid jq (((",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Success() with invalid jq = nil, want error")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("Success() with invalid jq wrote stderr %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterJQRuntimeErrorPreservesTypedError(t *testing.T) {
|
||||
// A valid expression that fails at runtime must surface jq's own typed error
|
||||
// (an api error), not a wrapped internal output error, and must emit no
|
||||
// partial stdout.
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
|
||||
Format: "json",
|
||||
JQ: `error("boom")`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Success() with a runtime jq error = nil, want error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category == errs.CategoryInternal {
|
||||
t.Fatalf("Success() jq runtime error problem = %#v, %v; want jq's own typed error, not internal", problem, ok)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "jq error") {
|
||||
t.Fatalf("Success() jq runtime error = %v, want jq's own error message preserved", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("Success() jq runtime error wrote stdout %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterUnknownFormatStructKeepsNotice(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
type payload struct {
|
||||
OK bool `json:"ok"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
NoticeProvider: func() map[string]interface{} {
|
||||
return map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}}
|
||||
},
|
||||
})
|
||||
if err := emitter.Success(payload{OK: true, Value: "fixture"}, output.EmitOptions{Format: "yaml"}); err != nil {
|
||||
t.Fatalf("Success() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "_notice") {
|
||||
t.Fatalf("struct payload on unknown-format fallback dropped _notice:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -1,827 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Legacy oracle fixtures are frozen at base SHA 4a56748bfa941ff0ee0bfec92e65acac427732b0.
|
||||
// Golden regeneration is allowed only from that base, never from the current system under test.
|
||||
|
||||
package output_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type emitterCapture struct {
|
||||
stdout string
|
||||
stderr string
|
||||
err error
|
||||
}
|
||||
|
||||
type emitterSafetyProvider struct {
|
||||
alert *extcs.Alert
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *emitterSafetyProvider) Name() string { return "emitter-oracle" }
|
||||
|
||||
func (p *emitterSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.alert, p.err
|
||||
}
|
||||
|
||||
const (
|
||||
runtimeContextLegacyGoldenPath = "testdata/runtime_context_legacy.golden.json"
|
||||
writeSuccessEnvelopeLegacyGoldenPath = "testdata/write_success_envelope_legacy.golden.json"
|
||||
)
|
||||
|
||||
type runtimeContextOracleCase struct {
|
||||
name string
|
||||
data func() interface{}
|
||||
raw bool
|
||||
ok bool
|
||||
meta *output.Meta
|
||||
jq string
|
||||
format string
|
||||
useFormat bool
|
||||
pretty bool
|
||||
notice map[string]interface{}
|
||||
safetyMode string
|
||||
safetyAlert *extcs.Alert
|
||||
safetyErr error
|
||||
}
|
||||
|
||||
type runtimeContextLegacyGolden struct {
|
||||
Cases map[string]emitterCaptureGolden `json:"cases"`
|
||||
}
|
||||
|
||||
type writeSuccessEnvelopeOracleCase struct {
|
||||
name string
|
||||
data func() interface{}
|
||||
dryRun bool
|
||||
jq string
|
||||
notice map[string]interface{}
|
||||
safetyMode string
|
||||
safetyAlert *extcs.Alert
|
||||
}
|
||||
|
||||
type writeSuccessEnvelopeLegacyGolden struct {
|
||||
Cases map[string]emitterCaptureGolden `json:"cases"`
|
||||
}
|
||||
|
||||
type emitterCaptureGolden struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Error *emitterErrorGolden `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type emitterErrorGolden struct {
|
||||
GoType string `json:"go_type"`
|
||||
JSON json.RawMessage `json:"json"`
|
||||
Message string `json:"message"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
}
|
||||
|
||||
func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
|
||||
previousNotice := output.PendingNotice
|
||||
t.Cleanup(func() {
|
||||
output.PendingNotice = previousNotice
|
||||
extcs.Register(nil)
|
||||
})
|
||||
|
||||
cases := []runtimeContextOracleCase{
|
||||
{
|
||||
name: "json_object",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1", "enabled": true}
|
||||
},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "raw_json_preserves_html",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"html": "<p>a&b</p>"}
|
||||
},
|
||||
raw: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "format_raw_json_preserves_html",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"html": "<p>a&b</p>"}
|
||||
},
|
||||
raw: true,
|
||||
ok: true,
|
||||
format: "json",
|
||||
useFormat: true,
|
||||
},
|
||||
{
|
||||
name: "partial_failure_ok_false",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"succeeded": 1, "failed": 1}
|
||||
},
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "metadata",
|
||||
data: func() interface{} {
|
||||
return []interface{}{map[string]interface{}{"id": "1"}}
|
||||
},
|
||||
ok: true,
|
||||
meta: &output.Meta{Count: 1, Rollback: "lark-cli fixture rollback"},
|
||||
},
|
||||
{
|
||||
name: "jq_scalar",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"name": "Alice", "age": 30}
|
||||
},
|
||||
ok: true,
|
||||
jq: ".data.name",
|
||||
},
|
||||
{
|
||||
name: "raw_jq_complex",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"document": map[string]interface{}{"html": "<p>a&b</p>"}}
|
||||
},
|
||||
raw: true,
|
||||
ok: true,
|
||||
jq: ".data.document",
|
||||
},
|
||||
{
|
||||
name: "jq_invalid_expression",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1"}
|
||||
},
|
||||
ok: false,
|
||||
jq: "invalid[",
|
||||
},
|
||||
{
|
||||
name: "notice",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1"}
|
||||
},
|
||||
ok: true,
|
||||
notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}},
|
||||
},
|
||||
{
|
||||
name: "pretty",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"name": "Alice"}
|
||||
},
|
||||
ok: true,
|
||||
format: "pretty",
|
||||
useFormat: true,
|
||||
pretty: true,
|
||||
},
|
||||
{
|
||||
name: "pretty_without_renderer",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"name": "Alice"}
|
||||
},
|
||||
ok: true,
|
||||
format: "pretty",
|
||||
useFormat: true,
|
||||
},
|
||||
{
|
||||
name: "ndjson",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"id": "1"},
|
||||
map[string]interface{}{"id": "2"},
|
||||
}}
|
||||
},
|
||||
ok: true,
|
||||
format: "ndjson",
|
||||
useFormat: true,
|
||||
},
|
||||
{
|
||||
name: "table_with_safety_warning",
|
||||
data: func() interface{} {
|
||||
return []interface{}{map[string]interface{}{"id": "1", "name": "Alice"}}
|
||||
},
|
||||
ok: true,
|
||||
format: "table",
|
||||
useFormat: true,
|
||||
safetyMode: "warn",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
data: func() interface{} {
|
||||
return []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
map[string]interface{}{"id": "2", "name": "Bob"},
|
||||
}
|
||||
},
|
||||
ok: true,
|
||||
format: "csv",
|
||||
useFormat: true,
|
||||
},
|
||||
{
|
||||
name: "jq_safety_alert_without_stderr_warning",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1"}
|
||||
},
|
||||
ok: true,
|
||||
jq: ".data.id",
|
||||
safetyMode: "warn",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scanner_error_fails_open",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1"}
|
||||
},
|
||||
ok: true,
|
||||
safetyMode: "warn",
|
||||
safetyErr: errors.New("scanner unavailable"),
|
||||
},
|
||||
{
|
||||
name: "scanner_block",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "blocked"}
|
||||
},
|
||||
ok: false,
|
||||
safetyMode: "block",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown_format_data_envelope_notice",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"ok": true, "value": "fixture"}
|
||||
},
|
||||
ok: true,
|
||||
format: "yaml",
|
||||
useFormat: true,
|
||||
notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}},
|
||||
},
|
||||
}
|
||||
|
||||
golden := loadRuntimeContextLegacyGolden(t)
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mode := tc.safetyMode
|
||||
if mode == "" {
|
||||
mode = "off"
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
|
||||
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert, err: tc.safetyErr})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
notice := tc.notice
|
||||
output.PendingNotice = func() map[string]interface{} { return notice }
|
||||
|
||||
want, ok := golden.Cases[tc.name]
|
||||
if !ok {
|
||||
t.Fatalf("frozen golden case %q is missing", tc.name)
|
||||
}
|
||||
|
||||
opts := runtimeOracleOptions{
|
||||
raw: tc.raw,
|
||||
ok: tc.ok,
|
||||
meta: tc.meta,
|
||||
jq: tc.jq,
|
||||
format: tc.format,
|
||||
useFormat: tc.useFormat,
|
||||
pretty: tc.pretty,
|
||||
}
|
||||
current := runEmitterWithRuntimeContextContract(tc.data(), output.EmitterConfig{
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
NoticeProvider: func() map[string]interface{} { return notice },
|
||||
}, tc.ok, output.EmitOptions{
|
||||
Raw: tc.raw,
|
||||
Meta: tc.meta,
|
||||
Format: tc.format,
|
||||
JQ: tc.jq,
|
||||
Pretty: emitterPrettyRenderer(tc.pretty),
|
||||
})
|
||||
|
||||
assertEmitterGolden(t, want, current)
|
||||
|
||||
integrated := runRuntimeContextOracle(t, tc.data(), opts)
|
||||
assertEmitterGolden(t, want, integrated)
|
||||
if tc.safetyMode == "block" {
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(current.err, &safetyErr) {
|
||||
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", current.err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if len(golden.Cases) != len(cases) {
|
||||
t.Fatalf("golden case count = %d, want %d", len(golden.Cases), len(cases))
|
||||
}
|
||||
|
||||
jqFailure := golden.Cases["jq_invalid_expression"]
|
||||
if !strings.HasPrefix(jqFailure.Stderr, "error: ") || !strings.HasSuffix(jqFailure.Stderr, "\n") {
|
||||
t.Fatalf("invalid jq golden stderr = %q, want error line ending in newline", jqFailure.Stderr)
|
||||
}
|
||||
if jqFailure.Error == nil || jqFailure.Error.ExitCode != output.ExitValidation {
|
||||
t.Fatalf("invalid jq golden exit = %#v, want %d", jqFailure.Error, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func loadRuntimeContextLegacyGolden(t *testing.T) runtimeContextLegacyGolden {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(runtimeContextLegacyGoldenPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read RuntimeContext legacy golden: %v", err)
|
||||
}
|
||||
var golden runtimeContextLegacyGolden
|
||||
if err := json.Unmarshal(contents, &golden); err != nil {
|
||||
t.Fatalf("decode RuntimeContext legacy golden: %v", err)
|
||||
}
|
||||
return golden
|
||||
}
|
||||
|
||||
func captureEmitterGolden(t *testing.T, capture emitterCapture) emitterCaptureGolden {
|
||||
t.Helper()
|
||||
golden := emitterCaptureGolden{Stdout: capture.stdout, Stderr: capture.stderr}
|
||||
if capture.err == nil {
|
||||
return golden
|
||||
}
|
||||
errorJSON, err := json.Marshal(capture.err)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal captured error %T: %v", capture.err, err)
|
||||
}
|
||||
golden.Error = &emitterErrorGolden{
|
||||
GoType: fmt.Sprintf("%T", capture.err),
|
||||
JSON: errorJSON,
|
||||
Message: capture.err.Error(),
|
||||
ExitCode: output.ExitCodeOf(capture.err),
|
||||
}
|
||||
return golden
|
||||
}
|
||||
|
||||
type runtimeOracleOptions struct {
|
||||
raw bool
|
||||
ok bool
|
||||
meta *output.Meta
|
||||
jq string
|
||||
format string
|
||||
useFormat bool
|
||||
pretty bool
|
||||
}
|
||||
|
||||
func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
|
||||
t.Helper()
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
parent := &cobra.Command{Use: "lark-cli"}
|
||||
cmd := &cobra.Command{Use: "fixture"}
|
||||
leaf := &cobra.Command{Use: "+emit"}
|
||||
parent.AddCommand(cmd)
|
||||
cmd.AddCommand(leaf)
|
||||
|
||||
factory := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}}
|
||||
runtime := common.TestNewRuntimeContextForAPI(
|
||||
context.Background(), leaf, &core.CliConfig{Brand: core.BrandFeishu}, factory, core.AsBot,
|
||||
)
|
||||
runtime.Format = opts.format
|
||||
runtime.JqExpr = opts.jq
|
||||
|
||||
pretty := func(w io.Writer) {
|
||||
fmt.Fprintln(w, "pretty:fixture")
|
||||
}
|
||||
if !opts.pretty {
|
||||
pretty = nil
|
||||
}
|
||||
|
||||
var err error
|
||||
switch {
|
||||
case opts.useFormat && opts.raw:
|
||||
runtime.OutFormatRaw(data, opts.meta, pretty)
|
||||
case opts.useFormat:
|
||||
runtime.OutFormat(data, opts.meta, pretty)
|
||||
case !opts.ok:
|
||||
err = runtime.OutPartialFailure(data, opts.meta)
|
||||
case opts.raw:
|
||||
runtime.OutRaw(data, opts.meta)
|
||||
default:
|
||||
runtime.Out(data, opts.meta)
|
||||
}
|
||||
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
|
||||
}
|
||||
|
||||
func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
config.Out = stdout
|
||||
config.ErrOut = stderr
|
||||
emitter := output.NewEmitter(config)
|
||||
var err error
|
||||
if ok {
|
||||
err = emitter.Success(data, opts)
|
||||
} else {
|
||||
err = emitter.PartialFailure(data, opts)
|
||||
}
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
|
||||
}
|
||||
|
||||
func runEmitterWithRuntimeContextContract(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
|
||||
capture := runEmitterSuccess(data, config, ok, opts)
|
||||
if capture.err != nil {
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if errors.As(capture.err, &safetyErr) {
|
||||
return capture
|
||||
}
|
||||
if opts.JQ != "" {
|
||||
capture.stderr += fmt.Sprintf("error: %v\n", capture.err)
|
||||
return capture
|
||||
}
|
||||
capture.err = nil
|
||||
}
|
||||
if !ok {
|
||||
capture.err = output.PartialFailure(output.ExitAPI)
|
||||
}
|
||||
return capture
|
||||
}
|
||||
|
||||
func emitterPrettyRenderer(enabled bool) output.PrettyRenderer {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
return func(w io.Writer, _ bool) error {
|
||||
_, err := fmt.Fprintln(w, "pretty:fixture")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterMatchesWriteSuccessEnvelopeLegacyOracle(t *testing.T) {
|
||||
previousNotice := output.PendingNotice
|
||||
t.Cleanup(func() {
|
||||
output.PendingNotice = previousNotice
|
||||
extcs.Register(nil)
|
||||
})
|
||||
|
||||
cases := []writeSuccessEnvelopeOracleCase{
|
||||
{
|
||||
name: "json",
|
||||
data: func() interface{} { return map[string]interface{}{"id": "1"} },
|
||||
},
|
||||
{
|
||||
name: "dry_run",
|
||||
data: func() interface{} { return map[string]interface{}{"api": []interface{}{}} },
|
||||
dryRun: true,
|
||||
},
|
||||
{
|
||||
name: "jq",
|
||||
data: func() interface{} { return map[string]interface{}{"id": "1"} },
|
||||
jq: ".data.id",
|
||||
},
|
||||
{
|
||||
name: "notice",
|
||||
data: func() interface{} { return map[string]interface{}{"id": "1"} },
|
||||
notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}},
|
||||
},
|
||||
{
|
||||
name: "jq_safety_warning",
|
||||
data: func() interface{} { return map[string]interface{}{"id": "1"} },
|
||||
jq: ".data.id",
|
||||
safetyMode: "warn",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scanner_block",
|
||||
data: func() interface{} { return map[string]interface{}{"id": "blocked"} },
|
||||
safetyMode: "block",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
}
|
||||
golden := loadWriteSuccessEnvelopeLegacyGolden(t)
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mode := tc.safetyMode
|
||||
if mode == "" {
|
||||
mode = "off"
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
|
||||
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
notice := tc.notice
|
||||
output.PendingNotice = func() map[string]interface{} { return notice }
|
||||
|
||||
want, ok := golden.Cases[tc.name]
|
||||
if !ok {
|
||||
t.Fatalf("frozen golden case %q is missing", tc.name)
|
||||
}
|
||||
|
||||
current := runEmitterSuccess(tc.data(), output.EmitterConfig{
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
NoticeProvider: func() map[string]interface{} { return notice },
|
||||
}, true, output.EmitOptions{
|
||||
Format: "",
|
||||
Raw: false,
|
||||
JQ: tc.jq,
|
||||
DryRun: tc.dryRun,
|
||||
JQSafetyWarning: true,
|
||||
})
|
||||
assertEmitterGolden(t, want, current)
|
||||
|
||||
integrated := runWriteSuccessEnvelopeOracle(tc.data(), tc.dryRun, tc.jq)
|
||||
assertEmitterGolden(t, want, integrated)
|
||||
})
|
||||
}
|
||||
|
||||
if len(golden.Cases) != len(cases) {
|
||||
t.Fatalf("golden case count = %d, want %d", len(golden.Cases), len(cases))
|
||||
}
|
||||
}
|
||||
|
||||
func loadWriteSuccessEnvelopeLegacyGolden(t *testing.T) writeSuccessEnvelopeLegacyGolden {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(writeSuccessEnvelopeLegacyGoldenPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read WriteSuccessEnvelope legacy golden: %v", err)
|
||||
}
|
||||
var golden writeSuccessEnvelopeLegacyGolden
|
||||
if err := json.Unmarshal(contents, &golden); err != nil {
|
||||
t.Fatalf("decode WriteSuccessEnvelope legacy golden: %v", err)
|
||||
}
|
||||
return golden
|
||||
}
|
||||
|
||||
func runWriteSuccessEnvelopeOracle(data interface{}, dryRun bool, jq string) emitterCapture {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
err := output.WriteSuccessEnvelope(data, output.SuccessEnvelopeOptions{
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
DryRun: dryRun,
|
||||
JqExpr: jq,
|
||||
Out: stdout,
|
||||
ErrOut: stderr,
|
||||
})
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
|
||||
}
|
||||
|
||||
func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
type oracleCase struct {
|
||||
name string
|
||||
format output.Format
|
||||
safetyMode string
|
||||
safetyAlert *extcs.Alert
|
||||
}
|
||||
cases := []oracleCase{
|
||||
{name: "ndjson", format: output.FormatNDJSON},
|
||||
{name: "table", format: output.FormatTable},
|
||||
{name: "csv", format: output.FormatCSV},
|
||||
{
|
||||
name: "warn",
|
||||
format: output.FormatNDJSON,
|
||||
safetyMode: "warn",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "block",
|
||||
format: output.FormatTable,
|
||||
safetyMode: "block",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pages := []interface{}{
|
||||
[]interface{}{map[string]interface{}{"id": "1", "name": "Alice"}},
|
||||
[]interface{}{map[string]interface{}{"id": "2", "name": "Bob", "ignored": true}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mode := tc.safetyMode
|
||||
if mode == "" {
|
||||
mode = "off"
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
|
||||
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
legacy := runPaginationOracle(pages, tc.format)
|
||||
current := runEmitterStreamPages(pages, tc.format.String())
|
||||
|
||||
assertEmitterBytes(t, legacy, current)
|
||||
assertEquivalentError(t, legacy.err, current.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runPaginationOracle(pages []interface{}, format output.Format) emitterCapture {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
formatter := output.NewPaginatedFormatter(stdout, format)
|
||||
var emitErr error
|
||||
for _, page := range pages {
|
||||
scanResult := output.ScanForSafety("lark-cli fixture +emit", page, stderr)
|
||||
if scanResult.Blocked {
|
||||
emitErr = scanResult.BlockErr
|
||||
break
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(stderr, scanResult.Alert)
|
||||
}
|
||||
formatter.FormatPage(page)
|
||||
}
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
|
||||
}
|
||||
|
||||
func runEmitterStreamPages(pages []interface{}, format string) emitterCapture {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: stderr,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
})
|
||||
var emitErr error
|
||||
for _, page := range pages {
|
||||
if emitErr = emitter.StreamPage(page, output.StreamOptions{Format: format}); emitErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
|
||||
}
|
||||
|
||||
func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
|
||||
previousNotice := output.PendingNotice
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
return map[string]interface{}{"source": "global"}
|
||||
}
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
colorSeen := false
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: stderr,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
ColorEnabled: true,
|
||||
NoticeProvider: func() map[string]interface{} {
|
||||
return map[string]interface{}{"source": "captured"}
|
||||
},
|
||||
})
|
||||
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
|
||||
t.Fatalf("notice source was not captured by Emitter:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "pretty",
|
||||
Pretty: func(w io.Writer, colorEnabled bool) error {
|
||||
colorSeen = colorEnabled
|
||||
_, err := fmt.Fprintln(w, "pretty")
|
||||
return err
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Emitter.Success(pretty) error = %v", err)
|
||||
}
|
||||
if !colorSeen {
|
||||
t.Fatal("PrettyRenderer did not receive captured ColorEnabled value")
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
if err := emitter.Success(map[string]interface{}{"ok": true, "id": "1"}, output.EmitOptions{Format: "yaml"}); err != nil {
|
||||
t.Fatalf("Emitter.Success(unknown format) error = %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
|
||||
t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
type failingEmitterWriter struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (w failingEmitterWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||
|
||||
func TestEmitterPropagatesOutputError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
sentinel := errors.New("write failed")
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: failingEmitterWriter{err: sentinel},
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
|
||||
Raw: true, Format: "json",
|
||||
JQ: ".data",
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmitterBytes(t *testing.T, legacy, current emitterCapture) {
|
||||
t.Helper()
|
||||
if legacy.stdout != current.stdout {
|
||||
t.Fatalf("stdout byte mismatch\nlegacy (%d bytes):\n%q\nEmitter (%d bytes):\n%q",
|
||||
len(legacy.stdout), legacy.stdout, len(current.stdout), current.stdout)
|
||||
}
|
||||
if legacy.stderr != current.stderr {
|
||||
t.Fatalf("stderr byte mismatch\nlegacy (%d bytes):\n%q\nEmitter (%d bytes):\n%q",
|
||||
len(legacy.stderr), legacy.stderr, len(current.stderr), current.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmitterGolden(t *testing.T, want emitterCaptureGolden, current emitterCapture) {
|
||||
t.Helper()
|
||||
if want.Stdout != current.stdout {
|
||||
t.Fatalf("stdout byte mismatch\ngolden (%d bytes):\n%q\ncurrent (%d bytes):\n%q",
|
||||
len(want.Stdout), want.Stdout, len(current.stdout), current.stdout)
|
||||
}
|
||||
if want.Stderr != current.stderr {
|
||||
t.Fatalf("stderr byte mismatch\ngolden (%d bytes):\n%q\ncurrent (%d bytes):\n%q",
|
||||
len(want.Stderr), want.Stderr, len(current.stderr), current.stderr)
|
||||
}
|
||||
got := captureEmitterGolden(t, current)
|
||||
if (want.Error == nil) != (got.Error == nil) {
|
||||
t.Fatalf("error presence mismatch: golden=%#v current=%#v", want.Error, got.Error)
|
||||
}
|
||||
if want.Error == nil {
|
||||
return
|
||||
}
|
||||
if want.Error.GoType != got.Error.GoType || want.Error.Message != got.Error.Message || want.Error.ExitCode != got.Error.ExitCode {
|
||||
t.Fatalf("error mismatch:\ngolden: %#v\ncurrent: %#v", want.Error, got.Error)
|
||||
}
|
||||
var wantJSON interface{}
|
||||
if err := json.Unmarshal(want.Error.JSON, &wantJSON); err != nil {
|
||||
t.Fatalf("decode golden error JSON: %v", err)
|
||||
}
|
||||
var gotJSON interface{}
|
||||
if err := json.Unmarshal(got.Error.JSON, &gotJSON); err != nil {
|
||||
t.Fatalf("decode current error JSON: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(wantJSON, gotJSON) {
|
||||
t.Fatalf("error JSON mismatch:\ngolden: %s\ncurrent: %s", want.Error.JSON, got.Error.JSON)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEquivalentError(t *testing.T, legacy, current error) {
|
||||
t.Helper()
|
||||
if (legacy == nil) != (current == nil) {
|
||||
t.Fatalf("error presence mismatch: legacy=%v Emitter=%v", legacy, current)
|
||||
}
|
||||
if legacy == nil {
|
||||
return
|
||||
}
|
||||
legacyProblem, legacyOK := errs.ProblemOf(legacy)
|
||||
currentProblem, currentOK := errs.ProblemOf(current)
|
||||
if legacyOK != currentOK {
|
||||
t.Fatalf("typed error mismatch: legacy=%T Emitter=%T", legacy, current)
|
||||
}
|
||||
if legacyOK && !reflect.DeepEqual(legacyProblem, currentProblem) {
|
||||
t.Fatalf("problem mismatch:\nlegacy: %#v\nEmitter: %#v", legacyProblem, currentProblem)
|
||||
}
|
||||
}
|
||||
@@ -34,17 +34,27 @@ func SuccessEnvelopeData(result interface{}) interface{} {
|
||||
// JSON output carries content-safety alerts inside the envelope. When jq is
|
||||
// applied, the alert may be filtered away, so warn mode also writes stderr.
|
||||
func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
|
||||
return NewEmitter(EmitterConfig{
|
||||
Out: opts.Out,
|
||||
ErrOut: opts.ErrOut,
|
||||
CommandPath: opts.CommandPath,
|
||||
Identity: opts.Identity,
|
||||
NoticeProvider: GetNotice,
|
||||
}).Success(data, EmitOptions{
|
||||
Format: "",
|
||||
Raw: false,
|
||||
JQ: opts.JqExpr,
|
||||
DryRun: opts.DryRun,
|
||||
JQSafetyWarning: true,
|
||||
})
|
||||
scanResult := ScanForSafety(opts.CommandPath, data, opts.ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
|
||||
env := Envelope{
|
||||
OK: true,
|
||||
Identity: opts.Identity,
|
||||
DryRun: opts.DryRun,
|
||||
Data: data,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
env.ContentSafetyAlert = scanResult.Alert
|
||||
}
|
||||
if opts.JqExpr != "" {
|
||||
if scanResult.Alert != nil && opts.ErrOut != nil {
|
||||
WriteAlertWarning(opts.ErrOut, scanResult.Alert)
|
||||
}
|
||||
return JqFilter(opts.Out, env, opts.JqExpr)
|
||||
}
|
||||
PrintJson(opts.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -101,44 +101,34 @@ func ExtractItems(data interface{}) []interface{} {
|
||||
|
||||
// FormatValue formats a single response and writes it to w.
|
||||
func FormatValue(w io.Writer, data interface{}, format Format) {
|
||||
err := WriteFormatted(w, data, format)
|
||||
switch {
|
||||
case err == nil:
|
||||
return
|
||||
case isOutputMarshalError(err) && format == FormatNDJSON:
|
||||
legacyStderrf("ndjson marshal error: %v\n", err)
|
||||
case isOutputMarshalError(err):
|
||||
legacyStderrf("json marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteFormatted formats a single response and returns marshal or write errors.
|
||||
func WriteFormatted(w io.Writer, data interface{}, format Format) error {
|
||||
data = toGeneric(data)
|
||||
switch format {
|
||||
case FormatNDJSON:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
return WriteNDJSON(w, items)
|
||||
PrintNdjson(w, items)
|
||||
} else {
|
||||
PrintNdjson(w, data)
|
||||
}
|
||||
return WriteNDJSON(w, data)
|
||||
|
||||
case FormatTable:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
return WriteTable(w, items)
|
||||
FormatAsTable(w, items)
|
||||
} else {
|
||||
FormatAsTable(w, data)
|
||||
}
|
||||
return WriteTable(w, data)
|
||||
|
||||
case FormatCSV:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
return WriteCSV(w, items)
|
||||
FormatAsCSV(w, items)
|
||||
} else {
|
||||
FormatAsCSV(w, data)
|
||||
}
|
||||
return WriteCSV(w, data)
|
||||
|
||||
default: // FormatJSON
|
||||
return WriteJSON(w, data)
|
||||
PrintJson(w, data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,63 +148,49 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
|
||||
|
||||
// FormatPage formats one page of items.
|
||||
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
|
||||
err := pf.WritePage(data)
|
||||
if isOutputMarshalError(err) && (pf.Format == FormatJSON || pf.Format == FormatNDJSON) {
|
||||
legacyStderrf("ndjson marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WritePage formats one page of items and returns marshal or write errors.
|
||||
func (pf *PaginatedFormatter) WritePage(data interface{}) error {
|
||||
switch pf.Format {
|
||||
case FormatJSON, FormatNDJSON:
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
return WriteNDJSON(pf.W, arr)
|
||||
PrintNdjson(pf.W, arr)
|
||||
} else {
|
||||
PrintNdjson(pf.W, data)
|
||||
}
|
||||
return WriteNDJSON(pf.W, data)
|
||||
|
||||
case FormatTable:
|
||||
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
|
||||
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
|
||||
widths := computeColumnWidths(rows, cols)
|
||||
if isFirst {
|
||||
if err := writeHeader(w, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
writeHeader(w, cols, widths)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := writeRow(w, row, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
writeRow(w, row, cols, widths)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
case FormatCSV:
|
||||
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
|
||||
return writeCSVRows(w, rows, cols, isFirst)
|
||||
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
|
||||
writeCSVRows(w, rows, cols, isFirst)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatStructuredPage handles column-locking logic shared by table and csv.
|
||||
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool) error) error {
|
||||
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool)) {
|
||||
rows, pageCols, isList := prepareRows(data)
|
||||
if len(rows) == 0 {
|
||||
if pf.isFirstPage && isList {
|
||||
_, err := fmt.Fprintln(pf.W, "(empty)")
|
||||
return err
|
||||
fmt.Fprintln(pf.W, "(empty)")
|
||||
}
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
if pf.isFirstPage {
|
||||
// Lock columns from first page
|
||||
pf.cols = pageCols
|
||||
pf.isFirstPage = false
|
||||
return emit(pf.W, rows, pf.cols, true)
|
||||
emit(pf.W, rows, pf.cols, true)
|
||||
} else {
|
||||
// Reuse first page's columns — missing keys become empty, extra keys ignored
|
||||
return emit(pf.W, rows, pf.cols, false)
|
||||
emit(pf.W, rows, pf.cols, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -16,44 +15,12 @@ import (
|
||||
// PrintJson prints data as formatted JSON to w.
|
||||
func PrintJson(w io.Writer, data interface{}) {
|
||||
injectNotice(data)
|
||||
if err := WriteJSON(w, data); isOutputMarshalError(err) {
|
||||
legacyStderrf("json marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
type outputMarshalError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *outputMarshalError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *outputMarshalError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func isOutputMarshalError(err error) bool {
|
||||
var marshalErr *outputMarshalError
|
||||
return errors.As(err, &marshalErr)
|
||||
}
|
||||
|
||||
// legacyStderrf reports a leaf-formatter marshal/format failure on os.Stderr,
|
||||
// preserving the pre-Emitter behavior for direct (unmigrated) callers of the
|
||||
// Print*/FormatAs* wrappers. The Emitter never uses this — it returns typed
|
||||
// errors instead. Removed once the remaining direct callers migrate.
|
||||
func legacyStderrf(format string, args ...interface{}) {
|
||||
fmt.Fprintf(os.Stderr, format, args...) //nolint:forbidigo // legacy leaf-formatter stderr; removed in the output-ownership follow-up
|
||||
}
|
||||
|
||||
// WriteJSON writes data as formatted JSON to w and returns marshal or write errors.
|
||||
func WriteJSON(w io.Writer, data interface{}) error {
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return &outputMarshalError{err: err}
|
||||
fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
|
||||
return
|
||||
}
|
||||
_, err = fmt.Fprintln(w, string(b))
|
||||
return err
|
||||
fmt.Fprintln(w, string(b))
|
||||
}
|
||||
|
||||
// injectNotice adds a "_notice" field into CLI envelope maps.
|
||||
@@ -83,38 +50,21 @@ func injectNotice(data interface{}) {
|
||||
|
||||
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
|
||||
func PrintNdjson(w io.Writer, data interface{}) {
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if err := WriteNDJSON(w, item); isOutputMarshalError(err) {
|
||||
legacyStderrf("ndjson marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := WriteNDJSON(w, data); isOutputMarshalError(err) {
|
||||
legacyStderrf("ndjson marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteNDJSON writes data as NDJSON and returns marshal or write errors.
|
||||
func WriteNDJSON(w io.Writer, data interface{}) error {
|
||||
emit := func(item interface{}) error {
|
||||
emit := func(item interface{}) {
|
||||
b, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return &outputMarshalError{err: err}
|
||||
fmt.Fprintf(os.Stderr, "ndjson marshal error: %v\n", err)
|
||||
return
|
||||
}
|
||||
_, err = fmt.Fprintln(w, string(b))
|
||||
return err
|
||||
fmt.Fprintln(w, string(b))
|
||||
}
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if err := emit(item); err != nil {
|
||||
return err
|
||||
}
|
||||
emit(item)
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
emit(data)
|
||||
}
|
||||
return emit(data)
|
||||
}
|
||||
|
||||
func cellStr(val interface{}) string {
|
||||
|
||||
@@ -16,69 +16,50 @@ const maxColWidth = 100
|
||||
// - map[string]interface{} (single object) → key-value two-column table
|
||||
// - empty array → "(empty)"
|
||||
func FormatAsTable(w io.Writer, data interface{}) {
|
||||
if err := WriteTable(w, data); isOutputMarshalError(err) {
|
||||
legacyStderrf("json marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteTable formats data as a table and returns marshal or write errors.
|
||||
func WriteTable(w io.Writer, data interface{}) error {
|
||||
return WriteTablePaginated(w, data, true)
|
||||
FormatAsTablePaginated(w, data, true)
|
||||
}
|
||||
|
||||
// FormatAsTablePaginated formats data as a table with pagination awareness.
|
||||
// When isFirstPage is true, outputs the header; otherwise only data rows.
|
||||
func FormatAsTablePaginated(w io.Writer, data interface{}, isFirstPage bool) {
|
||||
if err := WriteTablePaginated(w, data, isFirstPage); isOutputMarshalError(err) {
|
||||
legacyStderrf("json marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteTablePaginated formats data as a table and returns marshal or write errors.
|
||||
func WriteTablePaginated(w io.Writer, data interface{}, isFirstPage bool) error {
|
||||
rows, cols, isList := prepareRows(data)
|
||||
if cols == nil {
|
||||
if isList {
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
} else {
|
||||
// Not a list and not an object — print as JSON fallback
|
||||
return WriteJSON(w, data)
|
||||
PrintJson(w, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if isFirstPage {
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
}
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
if !isList {
|
||||
// Single object: key-value two-column format
|
||||
return formatKeyValueTable(w, rows[0], cols)
|
||||
formatKeyValueTable(w, rows[0], cols)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate column widths (clamped to maxColWidth)
|
||||
widths := computeColumnWidths(rows, cols)
|
||||
|
||||
if isFirstPage {
|
||||
if err := writeHeader(w, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
writeHeader(w, cols, widths)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if err := writeRow(w, row, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
writeRow(w, row, cols, widths)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatKeyValueTable renders a single object as a two-column key-value table.
|
||||
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) error {
|
||||
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
|
||||
maxKeyWidth := 0
|
||||
for _, col := range cols {
|
||||
kw := stringWidth(col)
|
||||
@@ -90,11 +71,8 @@ func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) erro
|
||||
for _, col := range cols {
|
||||
val := row[col]
|
||||
val = truncateToWidth(val, maxColWidth)
|
||||
if _, err := fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeColumnWidths returns display widths for each column, clamped to maxColWidth.
|
||||
@@ -121,29 +99,25 @@ func computeColumnWidths(rows []map[string]string, cols []string) []int {
|
||||
}
|
||||
|
||||
// writeHeader writes the header row and separator line.
|
||||
func writeHeader(w io.Writer, cols []string, widths []int) error {
|
||||
func writeHeader(w io.Writer, cols []string, widths []int) {
|
||||
var header []string
|
||||
var sep []string
|
||||
for i, col := range cols {
|
||||
header = append(header, padToWidth(col, widths[i]))
|
||||
sep = append(sep, strings.Repeat("─", widths[i]))
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, strings.Join(header, " ")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(w, strings.Join(sep, " "))
|
||||
return err
|
||||
fmt.Fprintln(w, strings.Join(header, " "))
|
||||
fmt.Fprintln(w, strings.Join(sep, " "))
|
||||
}
|
||||
|
||||
// writeRow writes a single data row.
|
||||
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) error {
|
||||
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) {
|
||||
var cells []string
|
||||
for i, col := range cols {
|
||||
val := truncateToWidth(row[col], widths[i])
|
||||
cells = append(cells, padToWidth(val, widths[i]))
|
||||
}
|
||||
_, err := fmt.Fprintln(w, strings.Join(cells, " "))
|
||||
return err
|
||||
fmt.Fprintln(w, strings.Join(cells, " "))
|
||||
}
|
||||
|
||||
// padToWidth pads a string with spaces to reach the target display width.
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
{
|
||||
"cases": {
|
||||
"csv": {
|
||||
"stdout": "id,name\n1,Alice\n2,Bob\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"format_raw_json_preserves_html": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"jq_invalid_expression": {
|
||||
"stdout": "",
|
||||
"stderr": "error: invalid jq expression: unexpected EOF\n",
|
||||
"error": {
|
||||
"go_type": "*errs.ValidationError",
|
||||
"json": {
|
||||
"type": "validation",
|
||||
"subtype": "invalid_argument",
|
||||
"message": "invalid jq expression: unexpected EOF"
|
||||
},
|
||||
"message": "invalid jq expression: unexpected EOF",
|
||||
"exit_code": 2
|
||||
}
|
||||
},
|
||||
"jq_safety_alert_without_stderr_warning": {
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"jq_scalar": {
|
||||
"stdout": "Alice\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"json_object": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"enabled\": true,\n \"id\": \"1\"\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"metadata": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": [\n {\n \"id\": \"1\"\n }\n ],\n \"meta\": {\n \"count\": 1,\n \"rollback\": \"lark-cli fixture rollback\"\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"ndjson": {
|
||||
"stdout": "{\"id\":\"1\"}\n{\"id\":\"2\"}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"notice": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n },\n \"_notice\": {\n \"update\": {\n \"latest\": \"9.9.9\"\n }\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"partial_failure_ok_false": {
|
||||
"stdout": "{\n \"ok\": false,\n \"identity\": \"bot\",\n \"data\": {\n \"failed\": 1,\n \"succeeded\": 1\n }\n}\n",
|
||||
"stderr": "",
|
||||
"error": {
|
||||
"go_type": "*output.PartialFailureError",
|
||||
"json": {
|
||||
"Code": 1
|
||||
},
|
||||
"message": "partial failure (exit 1)",
|
||||
"exit_code": 1
|
||||
}
|
||||
},
|
||||
"pretty": {
|
||||
"stdout": "pretty:fixture\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"pretty_without_renderer": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"name\": \"Alice\"\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"raw_jq_complex": {
|
||||
"stdout": "{\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"raw_json_preserves_html": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"scanner_block": {
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"error": {
|
||||
"go_type": "*errs.ContentSafetyError",
|
||||
"json": {
|
||||
"type": "policy",
|
||||
"subtype": "content_safety",
|
||||
"message": "content safety violation detected (rules: fixture-rule)",
|
||||
"rules": [
|
||||
"fixture-rule"
|
||||
]
|
||||
},
|
||||
"message": "content safety violation detected (rules: fixture-rule)",
|
||||
"exit_code": 6
|
||||
}
|
||||
},
|
||||
"scanner_error_fails_open": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
|
||||
"stderr": "warning: content safety scan error: scanner unavailable\n"
|
||||
},
|
||||
"table_with_safety_warning": {
|
||||
"stdout": "id name \n── ─────\n1 Alice\n",
|
||||
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
|
||||
},
|
||||
"unknown_format_data_envelope_notice": {
|
||||
"stdout": "{\n \"_notice\": {\n \"skills\": {\n \"current\": \"1.0.0\"\n }\n },\n \"ok\": true,\n \"value\": \"fixture\"\n}\n",
|
||||
"stderr": "warning: unknown format \"yaml\", falling back to json\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"cases": {
|
||||
"dry_run": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"dry_run\": true,\n \"data\": {\n \"api\": []\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"jq": {
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"jq_safety_warning": {
|
||||
"stdout": "1\n",
|
||||
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
|
||||
},
|
||||
"json": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"notice": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n },\n \"_notice\": {\n \"update\": {\n \"latest\": \"9.9.9\"\n }\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"scanner_block": {
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"error": {
|
||||
"go_type": "*errs.ContentSafetyError",
|
||||
"json": {
|
||||
"type": "policy",
|
||||
"subtype": "content_safety",
|
||||
"message": "content safety violation detected (rules: fixture-rule)",
|
||||
"rules": [
|
||||
"fixture-rule"
|
||||
]
|
||||
},
|
||||
"message": "content safety violation detected (rules: fixture-rule)",
|
||||
"exit_code": 6
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,18 +19,12 @@ import (
|
||||
type eventPayload struct {
|
||||
Comment *struct {
|
||||
Body string `json:"body"`
|
||||
Path string `json:"path"`
|
||||
} `json:"comment"`
|
||||
Review *struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"review"`
|
||||
}
|
||||
|
||||
type commentContent struct {
|
||||
Body string
|
||||
Path string
|
||||
}
|
||||
|
||||
func main() {
|
||||
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
|
||||
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
|
||||
@@ -40,11 +34,12 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
diags, err := auditEvent(*eventPath, *kind)
|
||||
body, err := commentBody(*eventPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
diags := diagnostics(publiccontent.ScanComment(*kind, body))
|
||||
if len(diags) > 0 {
|
||||
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
|
||||
}
|
||||
@@ -52,44 +47,32 @@ func main() {
|
||||
os.Exit(report.ExitCode(diags))
|
||||
}
|
||||
|
||||
func auditEvent(eventPath, kind string) ([]report.Diagnostic, error) {
|
||||
content, err := commentBody(eventPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCommentContent(kind, content), nil
|
||||
}
|
||||
|
||||
func scanCommentContent(kind string, content commentContent) []report.Diagnostic {
|
||||
return diagnostics(publiccontent.ScanCommentAtPath(kind, content.Path, content.Body))
|
||||
}
|
||||
|
||||
func auditFailureSummary(count int) string {
|
||||
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
|
||||
}
|
||||
|
||||
func commentBody(path string) (commentContent, error) {
|
||||
func commentBody(path string) (string, error) {
|
||||
safePath, err := validate.SafeInputPath(path)
|
||||
if err != nil {
|
||||
return commentContent{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
WithParam("--event").
|
||||
WithCause(err)
|
||||
}
|
||||
data, err := vfs.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return commentContent{}, err
|
||||
return "", err
|
||||
}
|
||||
var payload eventPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return commentContent{}, err
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case payload.Comment != nil:
|
||||
return commentContent{Body: payload.Comment.Body, Path: payload.Comment.Path}, nil
|
||||
return payload.Comment.Body, nil
|
||||
case payload.Review != nil:
|
||||
return commentContent{Body: payload.Review.Body}, nil
|
||||
return payload.Review.Body, nil
|
||||
default:
|
||||
return commentContent{}, nil
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
)
|
||||
|
||||
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
@@ -34,92 +32,11 @@ func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got.Body != "clean comment" || got.Path != "" {
|
||||
t.Fatalf("comment content = %#v", got)
|
||||
if got != "clean comment" {
|
||||
t.Fatalf("comment body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentBodyReadsReviewCommentPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"test suggestion","path":"cmd/agent/list_test.go"}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
got, err := commentBody("event.json")
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got.Body != "test suggestion" || got.Path != "cmd/agent/list_test.go" {
|
||||
t.Fatalf("comment content = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentAuditUsesReviewCommentPathForFixtureClassification(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := `CLIENT_SECRET=$(security find-generic-password -w)`
|
||||
event := `{"comment":{"body":` + strconv.Quote(body) + `,"path":"scripts/config_test.sh"}}`
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
diags, err := auditEvent("event.json", "pull_request_review_comment")
|
||||
if err != nil {
|
||||
t.Fatalf("auditEvent() error = %v", err)
|
||||
}
|
||||
for _, diag := range diags {
|
||||
if diag.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("review comment fixture should not be a credential diagnostic: %#v", diags)
|
||||
}
|
||||
}
|
||||
pathless := publiccontent.ScanComment("pull_request_review_comment", body)
|
||||
for _, finding := range pathless {
|
||||
if finding.Rule == "public_content_generic_credential" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("test precondition failed: pathless comment should be classified as a credential: %#v", pathless)
|
||||
}
|
||||
|
||||
func TestScanCommentContentPreservesReviewCommentPath(t *testing.T) {
|
||||
providerValue := "gh" + "p_" + "1234567890abcdef" + "1234567890abcdef" + "1234"
|
||||
content := commentContent{
|
||||
Body: `cfg := &Config{AccessToken: "` + providerValue + `"}`,
|
||||
Path: "cmd/agent/list_test.go",
|
||||
}
|
||||
|
||||
diags := scanCommentContent("pull_request_review_comment", content)
|
||||
for _, diag := range diags {
|
||||
if diag.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
if diag.File != content.Path {
|
||||
t.Fatalf("credential diagnostic file = %q, want %q", diag.File, content.Path)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("missing provider credential diagnostic: %#v", diags)
|
||||
}
|
||||
|
||||
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "event.json")
|
||||
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
|
||||
|
||||
@@ -6,11 +6,10 @@ package diff
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
)
|
||||
|
||||
func TestScopeIncludesChangedSkillAndRelatedDomain(t *testing.T) {
|
||||
@@ -123,7 +122,8 @@ func writeFile(t *testing.T, repo, rel, content string) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
@@ -131,7 +131,8 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
|
||||
func gitOutput(t *testing.T, repo string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v", args, err)
|
||||
|
||||
@@ -6,11 +6,10 @@ package publiccontent
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
)
|
||||
|
||||
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
@@ -24,10 +23,9 @@ func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
runGit(t, repo, "add", "baseline.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
|
||||
|
||||
api_`+`key = "`+providerValue+`"
|
||||
api_`+`key = "example-public-key"
|
||||
`)
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
|
||||
@@ -201,14 +199,13 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"access_` + `token":"` + providerValue + `"}`,
|
||||
`{"client_` + `secret": "` + providerValue + `"}`,
|
||||
`{"tenantAccess` + `Token":"` + providerValue + `"}`,
|
||||
`{"github` + `Token":"` + providerValue + `"}`,
|
||||
`{"vendorApi` + `Key":"` + providerValue + `"}`,
|
||||
`{"slackBot` + `Token":"xoxb_` + `1234567890abcdef"}`,
|
||||
`{"access_` + `token":"real-json-token"}`,
|
||||
`{"client_` + `secret": "real ` + `secret value"}`,
|
||||
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
|
||||
`{"github` + `Token":"real-github-token"}`,
|
||||
`{"vendorApi` + `Key":"real-vendor-key"}`,
|
||||
`{"slackBot` + `Token":"xoxb-real-token"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add json config")
|
||||
@@ -218,7 +215,14 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
for _, forbidden := range []string{providerValue, "xoxb_" + "1234567890abcdef"} {
|
||||
for _, forbidden := range []string{
|
||||
"real-json-token",
|
||||
"real secret value",
|
||||
"real-tenant-camel-token",
|
||||
"real-github-token",
|
||||
"real-vendor-key",
|
||||
"xoxb-real-token",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -302,8 +306,8 @@ func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("angle-wrapped provider credential findings = %d, want 2: %#v", count, got)
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,12 +338,12 @@ func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("provider-shaped benign-key findings = %d, want 4: %#v", count, got)
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -354,11 +358,15 @@ func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
@@ -366,7 +374,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -383,7 +391,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, accessKey) {
|
||||
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
|
||||
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
@@ -424,7 +432,7 @@ func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
@@ -440,11 +448,15 @@ func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T)
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
|
||||
@@ -477,13 +489,12 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
@@ -495,7 +506,13 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -604,8 +621,7 @@ func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add scanner fixtures")
|
||||
|
||||
@@ -669,11 +685,10 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
|
||||
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN="+providerValue+"\n")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add special paths")
|
||||
|
||||
@@ -840,7 +855,8 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
if len(args) > 0 && args[0] == "commit" {
|
||||
args = append([]string{"commit", "--no-verify"}, args[1:]...)
|
||||
}
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
@@ -849,7 +865,8 @@ func runGit(t *testing.T, repo string, args ...string) {
|
||||
|
||||
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
|
||||
t.Helper()
|
||||
cmd := gitcmd.Command(repo, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
|
||||
@@ -4,15 +4,8 @@
|
||||
package publiccontent
|
||||
|
||||
func ScanComment(kind, body string) []Finding {
|
||||
return ScanCommentAtPath(kind, "", body)
|
||||
}
|
||||
|
||||
func ScanCommentAtPath(kind, path, body string) []Finding {
|
||||
if kind == "" {
|
||||
kind = "comment"
|
||||
}
|
||||
if path == "" {
|
||||
path = kind
|
||||
}
|
||||
return scanText(path, "comment", body, isDetectorRuleFile(path))
|
||||
return scanText(kind, "comment", body, false)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
|
||||
@@ -20,60 +17,3 @@ func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAllowsMermaidCredentialTerminology(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"```mermaid",
|
||||
"sequenceDiagram",
|
||||
" participant Client",
|
||||
" participant AccessTokenHashTransport",
|
||||
" participant SecurityPolicyTransport",
|
||||
" Client->>AccessTokenHashTransport: Send request with bearer token",
|
||||
" AccessTokenHashTransport->>AccessTokenHashTransport: Clone request and inject token hash",
|
||||
" Client -> ClientSecret: Resolve configured credential",
|
||||
" AccessTokenHashTransport->>SecurityPolicyTransport: Forward enriched request",
|
||||
"```",
|
||||
}, "\n")
|
||||
|
||||
got := ScanComment("issue_comment", body)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("mermaid credential terminology should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentDetectsCredentialAssignmentInsideMermaidMessage(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
credentialAssignment := "password=" + providerValue
|
||||
body := strings.Join([]string{
|
||||
"```mermaid",
|
||||
"sequenceDiagram",
|
||||
" Client->>Server: Send " + credentialAssignment,
|
||||
"```",
|
||||
}, "\n")
|
||||
|
||||
got := ScanComment("issue_comment", body)
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("credential assignment inside mermaid message should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAtPathAllowsTestFixtureCredentialPlaceholder(t *testing.T) {
|
||||
body := `cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret"}`
|
||||
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("review comment test fixture should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanCommentAtPathDetectsProviderCredentialInTestFile(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
body := `cfg := &Config{AccessToken: "` + providerValue + `"}`
|
||||
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("provider credential in review comment should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func credentialValueHasStrongEvidence(key, value string) bool {
|
||||
normalized := strings.TrimRight(strings.TrimSpace(value), ",;")
|
||||
normalized = strings.TrimSpace(strings.Trim(normalized, `"'<>`))
|
||||
candidates := credentialEvidenceCandidates(unwrapCredentialValue(normalized))
|
||||
for _, candidate := range candidates {
|
||||
if providerCredentialIdentifier(candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if isCredentialMetadataField(key) {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if highEntropyCredentialValue(strings.ToLower(candidate)) || base64PaddedCredentialValue(candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return percentEncodedCredentialValue(strings.ToLower(candidates[0])) ||
|
||||
commandSubstitutionLooksCredentialLike(strings.ToLower(normalized))
|
||||
}
|
||||
|
||||
func credentialEvidenceCandidates(value string) []string {
|
||||
candidates := []string{value}
|
||||
for range 3 {
|
||||
decoded, err := url.PathUnescape(value)
|
||||
if err != nil || decoded == value {
|
||||
break
|
||||
}
|
||||
candidates = append(candidates, decoded)
|
||||
value = decoded
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func isCredentialMetadataField(key string) bool {
|
||||
if isBenignTokenField(key) {
|
||||
return true
|
||||
}
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
switch parts[len(parts)-1] {
|
||||
case "hash", "id", "kind", "marker", "prefix", "transport":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func base64PaddedCredentialValue(value string) bool {
|
||||
if len(value) < 16 || !strings.HasSuffix(value, "=") {
|
||||
return false
|
||||
}
|
||||
if _, err := base64.StdEncoding.DecodeString(value); err != nil {
|
||||
return false
|
||||
}
|
||||
return shannonEntropy(strings.TrimRight(value, "=")) >= 3.5
|
||||
}
|
||||
|
||||
func percentEncodedCredentialValue(value string) bool {
|
||||
if len(value) < 16 {
|
||||
return false
|
||||
}
|
||||
var escapes int
|
||||
for i := 0; i+2 < len(value); i++ {
|
||||
if value[i] == '%' && isHexByte(value[i+1]) && isHexByte(value[i+2]) {
|
||||
escapes++
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
return escapes >= 2
|
||||
}
|
||||
|
||||
func isHexByte(value byte) bool {
|
||||
return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f')
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*(?::=|[:=])\s*(?:!!str\s+)?(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\x60[^\x60]*\x60)|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\x60\s,}\]]+))`)
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
|
||||
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
|
||||
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
|
||||
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
|
||||
@@ -383,63 +383,33 @@ func anglePlaceholderIdentifier(value string) bool {
|
||||
}
|
||||
|
||||
func credentialShapedValue(value string) bool {
|
||||
normalized := strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'<>`))
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
return credentialShapedIdentifier(normalized)
|
||||
}
|
||||
|
||||
func credentialShapedIdentifier(value string) bool {
|
||||
return providerCredentialIdentifier(value)
|
||||
}
|
||||
|
||||
func providerCredentialIdentifier(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
switch {
|
||||
case providerTokenWithBody(value, "sk_live_", 16, ""),
|
||||
providerTokenWithBody(value, "sk_test_", 16, ""),
|
||||
providerTokenWithBody(value, "ghp_", 16, ""),
|
||||
providerTokenWithBody(value, "gho_", 16, ""),
|
||||
providerTokenWithBody(value, "ghu_", 16, ""),
|
||||
providerTokenWithBody(value, "github_pat_", 16, "_"),
|
||||
providerTokenWithBody(value, "xoxb_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxp_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxa_", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxb-", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxp-", 16, "-"),
|
||||
providerTokenWithBody(value, "xoxa-", 16, "-"),
|
||||
awsAccessKeyIdentifier(value):
|
||||
case strings.HasPrefix(value, "sk_live_"),
|
||||
strings.HasPrefix(value, "sk_test_"),
|
||||
strings.HasPrefix(value, "ghp_"),
|
||||
strings.HasPrefix(value, "gho_"),
|
||||
strings.HasPrefix(value, "ghu_"),
|
||||
strings.HasPrefix(value, "github_pat_"),
|
||||
strings.HasPrefix(value, "xoxb_"),
|
||||
strings.HasPrefix(value, "xoxp_"),
|
||||
strings.HasPrefix(value, "xoxa_"):
|
||||
return true
|
||||
case strings.HasPrefix(value, "real-") &&
|
||||
(strings.Contains(value, "secret") ||
|
||||
strings.Contains(value, "token") ||
|
||||
strings.Contains(value, "key") ||
|
||||
strings.Contains(value, "password")):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func providerTokenWithBody(value, prefix string, minBodyLength int, separators string) bool {
|
||||
body, ok := strings.CutPrefix(value, prefix)
|
||||
if !ok || len(body) < minBodyLength {
|
||||
return false
|
||||
}
|
||||
for _, r := range body {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune(separators, r) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func awsAccessKeyIdentifier(value string) bool {
|
||||
if len(value) != 20 || (!strings.HasPrefix(value, "AKIA") && !strings.HasPrefix(value, "ASIA")) {
|
||||
return false
|
||||
}
|
||||
for _, r := range value[4:] {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resourceTokenPlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
switch normalized {
|
||||
|
||||
@@ -47,30 +47,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
|
||||
inPrivateKey = false
|
||||
}
|
||||
for _, location := range credentialAssignmentRE.FindAllStringIndex(line, -1) {
|
||||
rawMatch := line[location[0]:location[1]]
|
||||
if !validCredentialAssignmentStart(line, location[0], rawMatch) {
|
||||
continue
|
||||
}
|
||||
match := credentialAssignmentRE.FindStringSubmatch(rawMatch)
|
||||
if !isCredentialAssignmentMatch(rawMatch) {
|
||||
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
|
||||
if !isCredentialAssignmentMatch(match[0]) {
|
||||
continue
|
||||
}
|
||||
value := credentialAssignmentValue(match)
|
||||
keyName, _ := normalizedCredentialAssignmentKey(rawMatch)
|
||||
evidenceValue := value
|
||||
if sourceCodeFile(file) {
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, location[0], rawMatch); ok {
|
||||
evidenceValue = rhs
|
||||
}
|
||||
}
|
||||
if !(isWebhookCredentialKey(keyName) && webhookAssignmentValueLooksCredentialLike(value)) &&
|
||||
!credentialValueHasStrongEvidence(keyName, evidenceValue) {
|
||||
continue
|
||||
}
|
||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||
if value == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isBenignCodeCredentialExpression(file, line, location[0], rawMatch, value) ||
|
||||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
isPermissionScopeIdentifierAssignment(keyName, value) ||
|
||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||
@@ -79,7 +64,7 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
if looksLikeEqualityComparison(value) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(rawMatch)))
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
|
||||
}
|
||||
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
||||
if !isJWTToken(match) {
|
||||
@@ -138,43 +123,21 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
return out
|
||||
}
|
||||
|
||||
func validCredentialAssignmentStart(line string, start int, match string) bool {
|
||||
if start <= 0 || credentialAssignmentOperator(match) != ":" {
|
||||
return true
|
||||
}
|
||||
prefix := strings.TrimSpace(line[:start])
|
||||
for _, arrow := range []string{"-->>", "->>", "-->", "->"} {
|
||||
if strings.HasSuffix(prefix, arrow) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialAssignmentOperator(match string) string {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimSpace(match[len(key):])
|
||||
if strings.HasPrefix(rest, ":=") {
|
||||
return ":="
|
||||
}
|
||||
if strings.HasPrefix(rest, ":") {
|
||||
return ":"
|
||||
}
|
||||
if strings.HasPrefix(rest, "=") {
|
||||
return "="
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCredentialAssignmentMatch(match string) bool {
|
||||
name, _, ok := normalizedCredentialAssignment(match)
|
||||
name, value, ok := normalizedCredentialAssignment(match)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name) || isWebhookCredentialKey(name)
|
||||
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
|
||||
return true
|
||||
}
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
}
|
||||
|
||||
func normalizedCredentialAssignmentKey(match string) (string, bool) {
|
||||
@@ -325,7 +288,7 @@ func tokenLikePlaceholderKey(key string) bool {
|
||||
|
||||
func tokenLikePlaceholderValue(key, value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || credentialShapedIdentifier(strings.Trim(value, `"'`)) {
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
if authCredentialTokenKey(key) {
|
||||
@@ -360,8 +323,52 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
||||
return stars >= 6 && alnum > 0
|
||||
}
|
||||
|
||||
func isWeakTokenCredentialKey(key string) bool {
|
||||
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
|
||||
return false
|
||||
}
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func isStrongTokenCredentialKey(key string) bool {
|
||||
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "token"},
|
||||
{"secret", "token"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func weakTokenValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
if normalized == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isPlaceholderValue(value) {
|
||||
return false
|
||||
}
|
||||
candidate := unwrapCredentialValue(normalized)
|
||||
return credentialShapedIdentifier(candidate) ||
|
||||
highEntropyCredentialValue(candidate) ||
|
||||
commandSubstitutionLooksCredentialLike(normalized) ||
|
||||
(strings.Contains(normalized, "://") &&
|
||||
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
|
||||
}
|
||||
|
||||
func unwrapCredentialValue(value string) string {
|
||||
value = strings.TrimSpace(strings.Trim(value, "\"'<>`"))
|
||||
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
|
||||
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
|
||||
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
}
|
||||
@@ -481,20 +488,17 @@ func numericStringPlaceholderValue(value string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isBenignCodeCredentialExpression(file, line string, matchStart int, match, value string) bool {
|
||||
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) {
|
||||
if !sourceCodeFile(file) || credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, matchStart, match); ok {
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
|
||||
return isBenignTypedCredentialRHS(rhs)
|
||||
}
|
||||
if credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
|
||||
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
|
||||
return true
|
||||
@@ -514,16 +518,17 @@ func isBenignCodeCredentialExpression(file, line string, matchStart int, match,
|
||||
return codeReferenceExpression(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (string, bool) {
|
||||
if matchStart < 0 || matchStart+len(match) > len(line) || line[matchStart:matchStart+len(match)] != match {
|
||||
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
|
||||
idx := strings.Index(line, match)
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[matchStart+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, ":=") {
|
||||
rest := strings.TrimSpace(line[idx+len(key):])
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
return "", false
|
||||
}
|
||||
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
|
||||
@@ -531,12 +536,7 @@ func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (st
|
||||
if assignmentIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
rhs := strings.TrimSpace(typeAndRHS[assignmentIdx+1:])
|
||||
parsed := credentialAssignmentRE.FindStringSubmatch("client_secret=" + rhs)
|
||||
if parsed == nil {
|
||||
return rhs, true
|
||||
}
|
||||
return credentialAssignmentValue(parsed), true
|
||||
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
|
||||
}
|
||||
|
||||
func isBenignTypedCredentialRHS(value string) bool {
|
||||
@@ -568,7 +568,7 @@ func credentialAssignmentRawValueQuoted(match string) bool {
|
||||
|
||||
func sourceCodeFile(file string) bool {
|
||||
switch filepath.Ext(file) {
|
||||
case ".go", ".js", ".jsx", ".py", ".sh", ".ts", ".tsx":
|
||||
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -593,7 +593,6 @@ func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
|
||||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
|
||||
sourceCodeCredentialTermLiteral(literal) ||
|
||||
sourceCodeCredentialPrefixLiteral(literal) ||
|
||||
sourceCodeStringExpressionLiteral(literal) ||
|
||||
sourceCodeVocabularyLiteral(literal) ||
|
||||
sourceCodeSchemaTypeLiteral(literal) ||
|
||||
benignCredentialStatusLiteral(literal)
|
||||
@@ -686,18 +685,6 @@ func sourceCodeCredentialPrefixLiteral(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCodeStringExpressionLiteral(value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" ||
|
||||
credentialShapedIdentifier(normalized) ||
|
||||
highEntropyCredentialValue(strings.ToLower(normalized)) {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(normalized, "${") ||
|
||||
strings.Contains(normalized, "$(") ||
|
||||
(strings.Contains(normalized, `\b`) && strings.ContainsAny(normalized, "|[]{}()+*?"))
|
||||
}
|
||||
|
||||
func sourceCodeVocabularyLiteral(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "bot", "tenant", "user":
|
||||
@@ -766,7 +753,7 @@ func codeIdentifier(value string) bool {
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[", `\`:
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -993,7 +980,6 @@ func credentialURLPasswordFixture(password string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||
switch normalized {
|
||||
case "p",
|
||||
"p%40ss",
|
||||
"pass",
|
||||
"password",
|
||||
"pat_abc",
|
||||
|
||||
@@ -251,22 +251,26 @@ func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsReadablePlaceholderMarkerSubstrings(t *testing.T) {
|
||||
func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"API_KEY=notredactedreal",
|
||||
"API_KEY=notplaceholdersecret",
|
||||
"API_KEY=abcxxxxreal",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable credential words should not be findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
|
||||
paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA"
|
||||
paddedTokenPrefix := "UTdrMm1O" + "OXBSNHZYOA"
|
||||
paddedTokenPrefix := "YWJj" + "ZGVmZ2g"
|
||||
paddedSecret := base64PaddedFixture(paddedSecretPrefix)
|
||||
paddedToken := base64PaddedFixture(paddedTokenPrefix)
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
@@ -290,25 +294,17 @@ func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsReadableBase64Lookalike(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte("client_secret=placeholder=\n"))
|
||||
if findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("readable base64 lookalike should not be a credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
jsonToken := providerValue
|
||||
jsonSecret := providerValue
|
||||
jsonKey := providerValue
|
||||
jsonTenantToken := providerValue
|
||||
jsonAppSecret := providerValue
|
||||
jsonPrefixedKey := providerValue
|
||||
jsonTenantCamelToken := providerValue
|
||||
jsonGithubToken := providerValue
|
||||
jsonVendorKey := providerValue
|
||||
jsonSlackBotToken := "xoxb_" + "1234567890abcdef"
|
||||
jsonToken := "real-json-token"
|
||||
jsonSecret := "real " + "secret value"
|
||||
jsonKey := "real-json-key"
|
||||
jsonTenantToken := "real-tenant-json-token"
|
||||
jsonAppSecret := "real-app-secret"
|
||||
jsonPrefixedKey := "real-prefixed-key"
|
||||
jsonTenantCamelToken := "real-tenant-camel-token"
|
||||
jsonGithubToken := "real-github-token"
|
||||
jsonVendorKey := "real-vendor-key"
|
||||
jsonSlackBotToken := "xoxb-real-token"
|
||||
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
|
||||
`{"access_` + `token":"` + jsonToken + `"}`,
|
||||
`{"client_` + `secret": "` + jsonSecret + `"}`,
|
||||
@@ -338,13 +334,12 @@ func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_OPENAI: " + providerValue,
|
||||
"TOKEN_GITHUB: " + providerValue,
|
||||
"CLIENT_SECRET_GOOGLE: " + providerValue,
|
||||
"SECRET_KEY_BASE: " + providerValue,
|
||||
"APP_PASSWORD_PROD: " + providerValue,
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -352,7 +347,13 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{providerValue} {
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
@@ -363,77 +364,85 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_OPENAI: prod_key",
|
||||
"CLIENT_SECRET_GOOGLE: prod_secret",
|
||||
"TOKEN_GITHUB: github_token",
|
||||
"APP_PASSWORD_PROD: prod_password",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: "API_KEY: <" + stripeLike + ">", want: true},
|
||||
{name: "github", text: "SECRET_TOKEN: <" + patLike + ">", want: true},
|
||||
{name: "readable", text: "CLIENT_SECRET: <real-client-secret-value>", want: false},
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY: <" + stripeLike + ">",
|
||||
"SECRET_TOKEN: <" + patLike + ">",
|
||||
"CLIENT_SECRET: <real-client-secret-value>",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
|
||||
})
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "expiry provider token", text: `{"access_token_expires_in":"` + patLike + `"}`, want: true},
|
||||
{name: "expiry provider secret", text: `{"refresh_token_expires_in":"` + stripeLike + `"}`, want: true},
|
||||
{name: "status readable", text: `{"client_secret_status":"real-client-secret-value"}`, want: false},
|
||||
{name: "name readable", text: `{"client_secret_name":"real-client-secret-value"}`, want: false},
|
||||
{name: "app provider token", text: `{"app_token":"` + patLike + `"}`, want: true},
|
||||
{name: "sync provider secret", text: `{"sync_token":"` + stripeLike + `"}`, want: true},
|
||||
{name: "target readable", text: `{"target_token":"real-client-secret-value"}`, want: false},
|
||||
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
|
||||
`{"access_token_expires_in":"` + patLike + `"}`,
|
||||
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
|
||||
`{"client_secret_status":"real-client-secret-value"}`,
|
||||
`{"client_secret_name":"real-client-secret-value"}`,
|
||||
`{"app_token":"` + patLike + `"}`,
|
||||
`{"sync_token":"` + stripeLike + `"}`,
|
||||
`{"target_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/public.json", tc.text, tc.want)
|
||||
})
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY_NAME: prod_key",
|
||||
"CLIENT_SECRET_NAME: prod_secret",
|
||||
"SECRET_STATUS: prod_secret",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsAccessKeyCredentials(t *testing.T) {
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
"ACCESS_KEY_ID: " + accessKey,
|
||||
@@ -584,18 +593,18 @@ func TestScanFileAllowsCredentialReferenceValues(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "provider", text: "API_KEY=${{" + stripeLike + "}}", want: true},
|
||||
{name: "readable", text: "TOKEN=${{real-secret-token-value}}", want: false},
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"API_KEY=${{" + stripeLike + "}}",
|
||||
"TOKEN=${{real-secret-token-value}}",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
|
||||
})
|
||||
if count != 2 {
|
||||
t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,7 +648,6 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
||||
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@proxy:8080"`,
|
||||
`proxy := "http://user:p%40ss@proxy:8080/path"`,
|
||||
`repo := "https://u:t@h/r.git"`,
|
||||
`target := "https://attacker:pw@open.feishu.cn"`,
|
||||
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
||||
@@ -813,151 +821,35 @@ func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsStrongAuthTokenKeysWithoutStrongValueEvidence(t *testing.T) {
|
||||
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
`{"access_token":"img_abc123"}`,
|
||||
`{"api_token":"img_live_secret"}`,
|
||||
`{"service_token":"ab********cd"}`,
|
||||
`{"bot_token":"board_v3_example"}`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("token field names alone should not produce findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(strings.Join([]string{
|
||||
`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`,
|
||||
`cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600)`,
|
||||
`rt := &stubRoundTripper{respBody: ` + "`" + `{"access_token":"t","token_type":"Bearer"}` + "`" + `}`,
|
||||
`envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_auto\nFEISHU_APP_SECRET=auto_secret\n"), 0600)`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600)`,
|
||||
`if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" {`,
|
||||
`if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" {`,
|
||||
`if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" {`,
|
||||
`return &core.CliConfig{AppID: "dummy", AppSecret: "dummy"}`,
|
||||
`os.WriteFile(path, []byte("API_KEY=replace-me\n"), 0600)`,
|
||||
`body := "APP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialIdentifierFields(t *testing.T) {
|
||||
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
|
||||
`"api_key_id": "k1",`,
|
||||
`"secret_id": "s1",`,
|
||||
`"token_id": "t1",`,
|
||||
`"private_key_id": "pk1",`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("credential identifier fields should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedIdentifierFieldValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
|
||||
`"api_key_id": "` + stripeLike + `",`,
|
||||
`"token_id": "` + githubToken + `",`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped identifier field findings = %d, want 2: %#v", count, got)
|
||||
if count != 4 {
|
||||
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialShapedValueTrimsWhitespaceBeforeDelimiters(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
if !credentialShapedValue(` "` + providerValue + `" `) {
|
||||
t.Fatal("space-padded quoted provider credential should be recognized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsProviderCredentialsAcrossAssignmentSyntaxes(t *testing.T) {
|
||||
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
text string
|
||||
}{
|
||||
{name: "Go raw string", path: "pkg/config.go", text: "const clientSecret = `" + providerValue + "`"},
|
||||
{name: "TypeScript template literal", path: "pkg/config.ts", text: "const clientSecret = `" + providerValue + "`;"},
|
||||
{name: "shell backtick", path: "scripts/config.sh", text: "client_secret=`" + providerValue + "`"},
|
||||
{name: "YAML string tag", path: "docs/config.yaml", text: "client_secret: !!str " + providerValue},
|
||||
{name: "YAML string tag double quoted", path: "docs/config.yaml", text: `client_secret: !!str "` + providerValue + `"`},
|
||||
{name: "YAML string tag single quoted", path: "docs/config.yaml", text: `client_secret: !!str '` + providerValue + `'`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ScanFile(tt.path, []byte(tt.text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("provider credential should be reported: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPercentEncodedProviderCredential(t *testing.T) {
|
||||
providerBody := strings.Join([]string{"1234567890abcdef", "1234567890abcdef", "1234"}, "")
|
||||
tests := []string{
|
||||
"access_token: ghp%" + "5F" + providerBody,
|
||||
"access_token_hash: ghp%" + "255F" + providerBody,
|
||||
}
|
||||
for _, text := range tests {
|
||||
got := ScanFile("docs/config.yaml", []byte(text+"\n"))
|
||||
if !findingRules(got)["public_content_generic_credential"] {
|
||||
t.Fatalf("percent-encoded provider credential should be reported: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileRequiresCompleteProviderCredentialFormats(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
|
||||
"token_type: asian",
|
||||
"token_prefix: ASIA",
|
||||
"token_prefix: ghp_",
|
||||
"api_key: sk_live_example",
|
||||
"token_prefix: asianmarketsegment01",
|
||||
"token_prefix: ghp_placeholder_value",
|
||||
}, "\n")+"\n"))
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("incomplete provider prefixes should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsEncodedTokenMetadataURL(t *testing.T) {
|
||||
got := ScanFile("docs/config.yaml", []byte("token_url: https%3A%2F%2Fexample.invalid/oauth/token\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("encoded token metadata URL should not be credential finding: %#v", got)
|
||||
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte(strings.Join([]string{
|
||||
"var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)",
|
||||
"REALISTIC_TOKEN_RE=\"\\\"${TOKEN_BODY}\\\"|\\`${TOKEN_BODY}\\`|\\\\b${TOKEN_BODY}\\\\b\"",
|
||||
}, "\n")+"\n"))
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
|
||||
@@ -1035,22 +927,6 @@ func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsSourceCodeSyntheticCredentialIdentifiers(t *testing.T) {
|
||||
got := ScanFile("fixtures/sheets_media.go", []byte(strings.Join([]string{
|
||||
`const fakeOfficeTokenPrefix = "fake_office_"`,
|
||||
`const localOfficeTokenPrefix = "local_office_"`,
|
||||
`const imageLiveSecretMarker = "img_live_secret"`,
|
||||
`const imageProdKeyMarker = "img_prod_key"`,
|
||||
`if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {`,
|
||||
`if strings.HasPrefix(spreadsheetToken, localOfficeTokenPrefix) {`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("source code token prefix references should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`app_secret=***`,
|
||||
@@ -1065,18 +941,22 @@ func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
|
||||
"client_secret=realprefix***realsuffix",
|
||||
"client_secret=ab********cd",
|
||||
"access_token=ab********cd",
|
||||
"refresh_token=realprefix********realsuffix",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("partially masked values should not be credential findings: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
@@ -1092,7 +972,6 @@ func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
file string
|
||||
@@ -1101,47 +980,32 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
{
|
||||
name: "typescript simple secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `"`,
|
||||
text: `const clientSecret: string = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "typescript terminated secret",
|
||||
name: "typescript numeric password",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `";`,
|
||||
},
|
||||
{
|
||||
name: "typescript secret with trailing comment",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `"; // production`,
|
||||
},
|
||||
{
|
||||
name: "typescript asserted secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string = "` + providerValue + `" as const;`,
|
||||
},
|
||||
{
|
||||
name: "typescript provider password",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const password: string = "` + providerValue + `"`,
|
||||
text: `const password: string = "12345678901234567890"`,
|
||||
},
|
||||
{
|
||||
name: "typescript union secret",
|
||||
file: "fixtures/source_secret.ts",
|
||||
text: `const clientSecret: string | undefined = "` + providerValue + `"`,
|
||||
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python simple secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str = "` + providerValue + `"`,
|
||||
text: `self.client_secret: str = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python union secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: str | None = "` + providerValue + `"`,
|
||||
text: `self.client_secret: str | None = "real-client-secret-value"`,
|
||||
},
|
||||
{
|
||||
name: "python optional secret",
|
||||
file: "fixtures/source_secret.py",
|
||||
text: `self.client_secret: Optional[str] = "` + providerValue + `"`,
|
||||
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -1154,154 +1018,24 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsRepeatedTypedCredentialAssignments(t *testing.T) {
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "placeholder";`, false)
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "`+providerValue+`";`, true)
|
||||
|
||||
got := ScanFile("fixtures/source_secret.ts", []byte(
|
||||
`const clientSecret: string = "placeholder"; const clientSecret: string = "`+providerValue+`";`+"\n",
|
||||
))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("repeated typed credential findings = %d, want 1: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: `const ClientSecret = "` + stripeLike + `"`, want: true},
|
||||
{name: "github", text: `const GithubToken = "` + githubToken + `"`, want: true},
|
||||
{name: "password number", text: `const Password = "12345678901234567890"`, want: false},
|
||||
{name: "secret number", text: `const ClientSecretNumber = "12345678901234567890"`, want: false},
|
||||
{name: "format literal", text: `const ClientSecretFormat = "abc%sdefreal"`, want: false},
|
||||
{name: "inline format literal", text: `fmt.Println("done"); const ClientSecret = "abc%sdefreal"`, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "fixtures/source_secret.go", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsGoShortDeclarationCredentials(t *testing.T) {
|
||||
providerSecret := "sk_" + "live_1234567890abcdef"
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
|
||||
`clientSecret := "` + providerSecret + `"`,
|
||||
`accessToken := "` + providerToken + `"`,
|
||||
`const ClientSecret = "real-client-secret-value"`,
|
||||
`const GithubToken = "` + githubToken + `"`,
|
||||
`const Password = "12345678901234567890"`,
|
||||
`const ClientSecretNumber = "12345678901234567890"`,
|
||||
`const ClientSecretFormat = "abc%sdefreal"`,
|
||||
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
|
||||
}, "\n")+"\n"))
|
||||
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("Go short declaration credential findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericCredentialDecisionMatrix(t *testing.T) {
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
|
||||
tokenHash := "6f1ed002ab559585" + "9014ebf0951522d9" +
|
||||
"a0e3c1f4206254d" + "28a13efbbc8d56a30"
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
text string
|
||||
comment bool
|
||||
want bool
|
||||
}{
|
||||
{name: "source synthetic token prefix", path: "pkg/sheets.go", text: `const localOfficeTokenPrefix = "local_office_"`, want: false},
|
||||
{name: "source token kind state", path: "pkg/client.py", text: `self._token_kind: TokenKind | None = None`, want: false},
|
||||
{name: "documentation token prefix", path: "docs/config.yaml", text: `token_prefix: local_office_`, want: false},
|
||||
{name: "documentation token kind", path: "docs/config.yaml", text: `token_kind: bearer`, want: false},
|
||||
{name: "documentation token hash", path: "docs/config.yaml", text: `access_token_hash: ` + tokenHash, want: false},
|
||||
{name: "comment fixture placeholder", text: `AppSecret: "fake-secret"`, comment: true, want: false},
|
||||
{name: "test fixture placeholder", path: "pkg/config_test.go", text: `AppSecret: "fake-secret"`, want: false},
|
||||
{name: "test real-labeled token", path: "pkg/config_test.go", text: `token: "real-tenant-access-token"`, want: false},
|
||||
{name: "test ambiguous concrete secret word", path: "pkg/config_test.go", text: `AppSecret: "supersecret"`, want: false},
|
||||
{name: "resource token placeholder", path: "docs/images.md", text: `"token": "img_abc123"`, want: false},
|
||||
{name: "partially masked token", path: "docs/auth.md", text: `token=ab********cd`, want: false},
|
||||
{name: "source readable secret words", path: "pkg/config.go", text: `const AppSecret = "customer-prod-secret"`, want: false},
|
||||
{name: "documentation readable secret words", path: "docs/config.yaml", text: `client_secret: customer-prod-secret`, want: false},
|
||||
{name: "comment middle fixture marker", text: `API_KEY=prod-fake-key`, comment: true, want: false},
|
||||
{name: "comment negated fixture marker", text: `AppSecret: "not-fake-secret"`, comment: true, want: false},
|
||||
{name: "source with credential words", path: "pkg/config.go", text: `secretWithPassword := "hunter2"`, want: false},
|
||||
{name: "production filename containing sample", path: "pkg/sampler.go", text: `clientSecret := "customer-prod-secret"`, want: false},
|
||||
{name: "provider token under weak key", path: "docs/config.yaml", text: `token: ` + providerToken, want: true},
|
||||
{name: "provider token under hash key", path: "docs/config.yaml", text: `access_token_hash: ` + providerToken, want: true},
|
||||
{name: "high entropy strong secret", path: "docs/config.yaml", text: `client_secret: ` + highEntropyValue, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got []Finding
|
||||
if tt.comment {
|
||||
got = ScanComment("issue_comment", tt.text)
|
||||
} else {
|
||||
got = ScanFile(tt.path, []byte(tt.text+"\n"))
|
||||
}
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileClassifiesLowEvidenceTestFixtureCredentials(t *testing.T) {
|
||||
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{name: "human readable access token", value: "user-access-token", want: false},
|
||||
{name: "delimited secret value", value: "secret-value", want: false},
|
||||
{name: "underscored secret fixture", value: "plain_secret", want: false},
|
||||
{name: "short delimited fixture", value: "t-abc", want: false},
|
||||
{name: "embedded test marker", value: "perm-grant-test-secret-skip", want: false},
|
||||
{name: "real labeled fixture", value: "real-token", want: false},
|
||||
{name: "ambiguous concrete word", value: "supersecret", want: false},
|
||||
{name: "provider token", value: providerToken, want: true},
|
||||
{name: "high entropy secret", value: highEntropyValue, want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ScanFile("pkg/config_test.go", []byte(`AppSecret: "`+tt.value+`"`+"\n"))
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsLowEvidenceTestFixtureAssignmentSyntaxes(t *testing.T) {
|
||||
got := ScanFile("pkg/config_test.go", []byte(strings.Join([]string{
|
||||
`secret := "secret-value"`,
|
||||
`samplePassword := "sample-password"`,
|
||||
`bodyWithToken := "plain text body\\nDownload: https://example.com/file?token=tok_aaa\\n"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("low-evidence test fixture assignment should not be reported: %#v", got)
|
||||
}
|
||||
if count != 6 {
|
||||
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1382,10 +1116,9 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
|
||||
`{"client_token":"` + stripeLike + `"}`,
|
||||
`{"client_token":"` + githubToken + `"}`,
|
||||
`{"client_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -1419,10 +1152,9 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
|
||||
|
||||
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`{ "resource_token": "` + stripeLike + `" }`,
|
||||
`{ "block_token": "` + githubToken + `" }`,
|
||||
`{ "block_token": "real-client-secret-value" }`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
@@ -1636,43 +1368,39 @@ func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsInvalidProviderPlaceholderLookalikes(t *testing.T) {
|
||||
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"client_secret: " + stripeLike + "_HERE",
|
||||
"api_key: YOUR_" + stripeLike,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("invalid provider placeholder lookalike should not be blocked: %#v", got)
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{name: "stripe", text: "CLIENT_SECRET=%" + stripeLike + "%", want: true},
|
||||
{name: "github", text: "GITHUB_TOKEN=%" + patLike + "%", want: true},
|
||||
{name: "readable", text: "TOKEN=%real-secret-token-value%", want: false},
|
||||
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||
"CLIENT_SECRET=%" + stripeLike + "%",
|
||||
"GITHUB_TOKEN=%" + patLike + "%",
|
||||
"TOKEN=%real-secret-token-value%",
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertGenericCredentialFinding(t, "docs/config.md", tc.text, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertGenericCredentialFinding(t *testing.T, file, text string, want bool) {
|
||||
t.Helper()
|
||||
got := ScanFile(file, []byte(text+"\n"))
|
||||
if actual := findingRules(got)["public_content_generic_credential"]; actual != want {
|
||||
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, want, got)
|
||||
if count != 3 {
|
||||
t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
@@ -727,11 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) {
|
||||
return nil, fmt.Errorf("not a lark-cli command")
|
||||
}
|
||||
argv = truncateShellTail(argv)
|
||||
var jqValid bool
|
||||
argv, jqValid = stripDryRunJQFilter(argv)
|
||||
if jqValid {
|
||||
argv = forceDryRunJSONFormat(argv)
|
||||
}
|
||||
argv = forceDryRunJSONFormat(argv)
|
||||
hasDryRunArg := false
|
||||
dryRunEnabled := false
|
||||
for _, arg := range argv[1:] {
|
||||
@@ -780,73 +775,6 @@ func truncateShellTail(argv []string) []string {
|
||||
return argv
|
||||
}
|
||||
|
||||
// stripDryRunJQFilter removes valid output-only jq filters from the synthetic
|
||||
// dry-run invocation. Invalid jq syntax and incompatible output flags are left
|
||||
// untouched so the real CLI execution still rejects the documented command.
|
||||
// The bool reports whether other output normalization remains safe.
|
||||
func stripDryRunJQFilter(argv []string) ([]string, bool) {
|
||||
jqExpr, outputPath, format, hasJQ, jqHasValue := dryRunOutputFlags(argv)
|
||||
if !hasJQ {
|
||||
return argv, true
|
||||
}
|
||||
if !jqHasValue || output.ValidateJqFlags(jqExpr, outputPath, format) != nil {
|
||||
return argv, false
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(argv))
|
||||
for i := 0; i < len(argv); i++ {
|
||||
arg := argv[i]
|
||||
switch {
|
||||
case arg == "--":
|
||||
return append(out, argv[i:]...), true
|
||||
case arg == "--jq" || arg == "-q":
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--jq=") || strings.HasPrefix(arg, "-q="):
|
||||
continue
|
||||
default:
|
||||
out = append(out, arg)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func dryRunOutputFlags(argv []string) (jqExpr, outputPath, format string, hasJQ, jqHasValue bool) {
|
||||
for i := 1; i < len(argv); i++ {
|
||||
arg := argv[i]
|
||||
if arg == "--" {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case arg == "--jq" || arg == "-q":
|
||||
hasJQ = true
|
||||
jqHasValue = i+1 < len(argv)
|
||||
if jqHasValue {
|
||||
jqExpr = argv[i+1]
|
||||
i++
|
||||
}
|
||||
case strings.HasPrefix(arg, "--jq=") || strings.HasPrefix(arg, "-q="):
|
||||
hasJQ = true
|
||||
jqHasValue = true
|
||||
jqExpr = arg[strings.IndexByte(arg, '=')+1:]
|
||||
case arg == "--output":
|
||||
if i+1 < len(argv) {
|
||||
outputPath = argv[i+1]
|
||||
i++
|
||||
}
|
||||
case strings.HasPrefix(arg, "--output="):
|
||||
outputPath = strings.TrimPrefix(arg, "--output=")
|
||||
case arg == "--format":
|
||||
if i+1 < len(argv) {
|
||||
format = argv[i+1]
|
||||
i++
|
||||
}
|
||||
case strings.HasPrefix(arg, "--format="):
|
||||
format = strings.TrimPrefix(arg, "--format=")
|
||||
}
|
||||
}
|
||||
return jqExpr, outputPath, format, hasJQ, jqHasValue
|
||||
}
|
||||
|
||||
func dryRunFlagExplicitlyTrue(arg string) bool {
|
||||
value, ok := strings.CutPrefix(arg, "--dry-run=")
|
||||
if !ok {
|
||||
|
||||
@@ -194,38 +194,6 @@ func TestRunDryRunsIgnoresTrailingShellComment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsIgnoresJQFilterWhenValidatingRequestPreview(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/flags"}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "im +flag-list",
|
||||
Runnable: true,
|
||||
Identities: []string{"user"},
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "as", TakesValue: true},
|
||||
{Name: "page-all"},
|
||||
{Name: "jq", Shorthand: "q", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: `lark-cli im +flag-list --as user --page-all -q '.data.flag_items[-1]'`,
|
||||
SourceFile: "skills/lark-im/references/lark-im-flag-list.md",
|
||||
Line: 26,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("jq example should remain executable: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"im", "+flag-list", "--as", "user", "--page-all", "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesPlaceholdersInsideJSONFlags(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/messages","params":{"chat_id":"oc_test123","page_token":"page_test123"}}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
@@ -827,72 +795,6 @@ func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgRemovesJQFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "short split",
|
||||
raw: `lark-cli im +flag-list --page-all -q '.data.flag_items[-1]'`,
|
||||
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "long split",
|
||||
raw: `lark-cli im +flag-list --jq '.data.flag_items[].item_id' --page-all`,
|
||||
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "short inline",
|
||||
raw: `lark-cli im +flag-list -q='.data.flag_items[-1]' --page-all`,
|
||||
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "long inline",
|
||||
raw: `lark-cli im +flag-list --jq='.data.flag_items[-1]' --page-all`,
|
||||
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "missing value remains invalid",
|
||||
raw: `lark-cli im +flag-list --page-all --jq`,
|
||||
want: []string{"im", "+flag-list", "--page-all", "--jq", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "next flag is not accepted as jq expression",
|
||||
raw: `lark-cli im +flag-list --jq --page-all`,
|
||||
want: []string{"im", "+flag-list", "--jq", "--page-all", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "invalid expression remains invalid",
|
||||
raw: `lark-cli im +flag-list --jq 'invalid[' --page-all`,
|
||||
want: []string{"im", "+flag-list", "--jq", "invalid[", "--page-all", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "incompatible pretty format remains invalid",
|
||||
raw: `lark-cli im +flag-list --jq '.data' --format pretty`,
|
||||
want: []string{"im", "+flag-list", "--jq", ".data", "--format", "pretty", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "compatible json format preserves request preview",
|
||||
raw: `lark-cli im +flag-list --jq '.data' --format json`,
|
||||
want: []string{"im", "+flag-list", "--format", "json", "--dry-run"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := appendDryRunArg(tt.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("appendDryRunArg() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"lark-cli mail +watch --format data --dry-run",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,7 +15,6 @@ import (
|
||||
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -203,8 +203,7 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
|
||||
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||
publicDoc := "api_" + "key = \"" + providerValue + "\"\n" +
|
||||
publicDoc := "api_" + "key = \"example-public-key\"\n" +
|
||||
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
|
||||
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -600,8 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
commandArgs := append([]string{"-c", "core.hooksPath=/dev/null"}, args...)
|
||||
cmd := gitcmd.Command(repo, commandArgs...)
|
||||
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -101,7 +101,6 @@ func TestSelectRecommendedScope_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestComputeMinimumScopeSet(t *testing.T) {
|
||||
ensureFreshRegistry(t)
|
||||
minSet := ComputeMinimumScopeSet("user")
|
||||
if len(minSet) == 0 {
|
||||
if len(ListFromMetaProjects()) == 0 {
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"services": [
|
||||
{
|
||||
"name": "calendar",
|
||||
"version": "v4",
|
||||
"title": "Calendar API",
|
||||
"servicePath": "/open-apis/calendar/v4",
|
||||
"resources": {
|
||||
"events": {
|
||||
"methods": {
|
||||
"create": {
|
||||
"path": "calendars/{calendar_id}/events",
|
||||
"httpMethod": "POST",
|
||||
"risk": "write",
|
||||
"scopes": [
|
||||
"calendar:calendar.event:create"
|
||||
],
|
||||
"parameters": {
|
||||
"calendar_id": {
|
||||
"type": "string",
|
||||
"location": "path",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "im",
|
||||
"version": "v1",
|
||||
"title": "IM API",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
"resources": {
|
||||
"chat.members": {
|
||||
"methods": {
|
||||
"create": {
|
||||
"path": "chats/{chat_id}/members",
|
||||
"httpMethod": "POST",
|
||||
"risk": "write",
|
||||
"scopes": [
|
||||
"im:chat",
|
||||
"im:chat.members:write_only"
|
||||
],
|
||||
"parameters": {
|
||||
"chat_id": {
|
||||
"type": "string",
|
||||
"location": "path",
|
||||
"required": true
|
||||
},
|
||||
"member_id_type": {
|
||||
"type": "string",
|
||||
"location": "query",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "task",
|
||||
"version": "v2",
|
||||
"title": "Task API",
|
||||
"servicePath": "/open-apis/task/v2",
|
||||
"resources": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package registrytest seeds the registry with a tracked metadata fixture so
|
||||
// command-tree tests pass on a clean checkout — no `make fetch_meta`, no
|
||||
// network, no user cache. TestMain funcs of packages that build service
|
||||
// commands call Seed after redirecting LARKSUITE_CLI_CONFIG_DIR.
|
||||
package registrytest
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// fixtureMetaJSON is a trimmed snapshot of the generated meta_data.json
|
||||
// holding only the calendar, im and task services that registry-backed tests
|
||||
// assert against. Its version is pinned to "0.0.1": newer than the empty
|
||||
// embedded stub ("0.0.0") so it wins on a clean checkout, older than any real
|
||||
// generated catalog ("1.0.0"+) so a `make fetch_meta` build keeps testing the
|
||||
// full embedded data.
|
||||
//
|
||||
//go:embed fixture_meta.json
|
||||
var fixtureMetaJSON []byte
|
||||
|
||||
// Seed writes fixtureMetaJSON into the registry remote-meta cache under
|
||||
// LARKSUITE_CLI_CONFIG_DIR and eagerly initializes the registry. testRoot must
|
||||
// be the temporary root created by the caller's TestMain; Seed rejects a config
|
||||
// directory outside it before performing any write. The cache
|
||||
// meta is stamped fresh so Init never sync-fetches or background-refreshes
|
||||
// over the network. Eager Init pins the catalog for the whole test process before
|
||||
// any individual test can re-point LARKSUITE_CLI_CONFIG_DIR elsewhere.
|
||||
//
|
||||
// The caller's TestMain must set LARKSUITE_CLI_CONFIG_DIR beneath testRoot
|
||||
// first; Seed refuses unset, mismatched, or escaping paths so it can never
|
||||
// write into a developer's real ~/.lark-cli.
|
||||
func Seed(testRoot string) error {
|
||||
configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR")
|
||||
if err := validateConfigDir(testRoot, configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fixture struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(fixtureMetaJSON, &fixture); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cacheDir := filepath.Join(configDir, "cache")
|
||||
if err := vfs.MkdirAll(cacheDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.json"), fixtureMetaJSON, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
cacheMeta, err := json.Marshal(registry.CacheMeta{
|
||||
LastCheckAt: time.Now().Unix(),
|
||||
Version: fixture.Version,
|
||||
Brand: string(core.BrandFeishu),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.meta.json"), cacheMeta, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Neutralize ambient knobs that would defeat the seeding: an inherited
|
||||
// LARKSUITE_CLI_REMOTE_META=off would stop Init from reading the seeded
|
||||
// cache at all, and LARKSUITE_CLI_META_TTL=0 would expire the freshness
|
||||
// stamp and start a background network refresh from inside unit tests.
|
||||
if err := os.Unsetenv("LARKSUITE_CLI_REMOTE_META"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Unsetenv("LARKSUITE_CLI_META_TTL"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry.Init()
|
||||
|
||||
// Init is a sync.Once, so the seed is pinned for the whole test process.
|
||||
// Turning remote metadata off afterwards cannot un-seed anything; it is a
|
||||
// guard for any future post-Init code path that might consult the remote
|
||||
// cache again after a test re-points LARKSUITE_CLI_CONFIG_DIR elsewhere.
|
||||
if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Self-check: both the fixture and any real generated catalog contain the
|
||||
// im service. If it is missing, the cache seeding silently stopped working
|
||||
// (e.g. the registry cache file names or freshness semantics changed) and
|
||||
// every registry-backed test would fail confusingly — fail loudly here
|
||||
// instead, pointing at this package.
|
||||
merged, ok := registry.ServiceTyped("im")
|
||||
if !ok {
|
||||
return errors.New("registrytest.Seed: registry has no im service after seeding — " +
|
||||
"the remote-cache format in internal/registry/remote.go may have changed; update registrytest to match")
|
||||
}
|
||||
|
||||
// Self-check: on a fetch_meta build the real embedded catalog must win over
|
||||
// the 0.0.1 fixture. If the merged im service diverges from the embedded
|
||||
// one, the version arbitration flipped (e.g. the generated catalog version
|
||||
// stopped parsing as semver) and unit tests would silently run against the
|
||||
// stale trimmed fixture instead of the fresh catalog.
|
||||
for _, service := range registry.EmbeddedServicesTyped() {
|
||||
if service.Name != "im" {
|
||||
continue
|
||||
}
|
||||
if service.Version != merged.Version {
|
||||
return errors.New("registrytest.Seed: the fixture shadowed the real embedded catalog — " +
|
||||
"check the meta_data.json version against the fixture's \"0.0.1\" arbitration in this package")
|
||||
}
|
||||
break
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateConfigDir guards the one real hazard: a TestMain wiring mistake
|
||||
// pointing LARKSUITE_CLI_CONFIG_DIR at a developer's real directory. Both
|
||||
// paths come from the caller's own MkdirTemp, so a plain containment check
|
||||
// is enough.
|
||||
func validateConfigDir(testRoot, configDir string) error {
|
||||
if testRoot == "" || configDir == "" {
|
||||
return errors.New("registrytest.Seed: test root and config dir must be set")
|
||||
}
|
||||
if !filepath.IsAbs(testRoot) || !filepath.IsAbs(configDir) {
|
||||
return errors.New("registrytest.Seed: test root and config dir must be absolute")
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.Clean(testRoot), filepath.Clean(configDir))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return errors.New("registrytest.Seed: config dir must stay inside the test root")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registrytest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
func TestValidateConfigDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tests := []struct {
|
||||
name string
|
||||
testRoot string
|
||||
configDir string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "equal", testRoot: root, configDir: root},
|
||||
{name: "child", testRoot: root, configDir: filepath.Join(root, "config")},
|
||||
{
|
||||
name: "sibling",
|
||||
testRoot: root,
|
||||
configDir: filepath.Join(filepath.Dir(root), "outside"),
|
||||
wantErr: true,
|
||||
},
|
||||
{name: "empty root", configDir: root, wantErr: true},
|
||||
{name: "empty config", testRoot: root, wantErr: true},
|
||||
{name: "relative root", testRoot: "relative", configDir: root, wantErr: true},
|
||||
{name: "relative config", testRoot: root, configDir: "relative", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateConfigDir(tt.testRoot, tt.configDir)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateConfigDir() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixtureContract(t *testing.T) {
|
||||
if len(fixtureMetaJSON) > 20<<10 {
|
||||
t.Fatalf("fixture size = %d, want <= %d", len(fixtureMetaJSON), 20<<10)
|
||||
}
|
||||
reg, err := meta.Parse(fixtureMetaJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("meta.Parse() error = %v", err)
|
||||
}
|
||||
if reg.Version != "0.0.1" {
|
||||
t.Fatalf("fixture version = %q, want 0.0.1", reg.Version)
|
||||
}
|
||||
|
||||
gotNames := make([]string, 0, len(reg.Services))
|
||||
for _, service := range reg.Services {
|
||||
gotNames = append(gotNames, service.Name)
|
||||
}
|
||||
sort.Strings(gotNames)
|
||||
if !slices.Equal(gotNames, []string{"calendar", "im", "task"}) {
|
||||
t.Fatalf("fixture services = %v, want [calendar im task]", gotNames)
|
||||
}
|
||||
|
||||
calendarCreate := fixtureMethod(t, reg, "calendar", "events", "create")
|
||||
assertMethodContract(t, calendarCreate, "calendars/{calendar_id}/events", http.MethodPost)
|
||||
calendarID, ok := calendarCreate.Parameters["calendar_id"]
|
||||
if !ok || calendarID.Location != "path" || !calendarID.Required {
|
||||
t.Fatalf("calendar_id = %+v, want required path parameter", calendarID)
|
||||
}
|
||||
if !slices.Contains(calendarCreate.Scopes, "calendar:calendar.event:create") {
|
||||
t.Fatalf("calendar create scopes = %v, want calendar:calendar.event:create", calendarCreate.Scopes)
|
||||
}
|
||||
|
||||
imCreate := fixtureMethod(t, reg, "im", "chat.members", "create")
|
||||
assertMethodContract(t, imCreate, "chats/{chat_id}/members", http.MethodPost)
|
||||
chatID, ok := imCreate.Parameters["chat_id"]
|
||||
if !ok || chatID.Location != "path" || !chatID.Required {
|
||||
t.Fatalf("chat_id = %+v, want required path parameter", chatID)
|
||||
}
|
||||
memberIDType, ok := imCreate.Parameters["member_id_type"]
|
||||
if !ok || memberIDType.Location != "query" || memberIDType.Required {
|
||||
t.Fatalf("member_id_type = %+v, want optional query parameter", memberIDType)
|
||||
}
|
||||
if imCreate.Risk != "write" {
|
||||
t.Fatalf("im create risk = %q, want write", imCreate.Risk)
|
||||
}
|
||||
for _, scope := range []string{"im:chat", "im:chat.members:write_only"} {
|
||||
if !slices.Contains(imCreate.Scopes, scope) {
|
||||
t.Fatalf("im create scopes = %v, want %s", imCreate.Scopes, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fixtureMethod(t *testing.T, reg meta.Registry, serviceName, resourceName, methodName string) meta.Method {
|
||||
t.Helper()
|
||||
for _, service := range reg.Services {
|
||||
if service.Name != serviceName {
|
||||
continue
|
||||
}
|
||||
resource, ok := service.Resource(resourceName)
|
||||
if !ok {
|
||||
t.Fatalf("fixture service %s has no resource %s", serviceName, resourceName)
|
||||
}
|
||||
method, ok := resource.Method(methodName)
|
||||
if !ok {
|
||||
t.Fatalf("fixture resource %s.%s has no method %s", serviceName, resourceName, methodName)
|
||||
}
|
||||
return method
|
||||
}
|
||||
t.Fatalf("fixture has no service %s", serviceName)
|
||||
return meta.Method{}
|
||||
}
|
||||
|
||||
func assertMethodContract(t *testing.T, method meta.Method, path, httpMethod string) {
|
||||
t.Helper()
|
||||
if method.Path != path || method.HTTPMethod != httpMethod {
|
||||
t.Fatalf("method = %s %s, want %s %s", method.HTTPMethod, method.Path, httpMethod, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedRejectsUnsafeConfigDir pins Seed's guard: it must return before
|
||||
// writing anything when LARKSUITE_CLI_CONFIG_DIR is unset or escapes the
|
||||
// caller's test root, so a TestMain wiring mistake can never touch a
|
||||
// developer's real ~/.lark-cli.
|
||||
func TestSeedRejectsUnsafeConfigDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
t.Run("unset config dir", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "")
|
||||
if err := Seed(root); err == nil {
|
||||
t.Fatal("Seed() error = nil, want unset config dir rejection")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("config dir outside test root", func(t *testing.T) {
|
||||
outside := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", outside)
|
||||
if err := Seed(root); err == nil {
|
||||
t.Fatal("Seed() error = nil, want containment rejection")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "cache")); err == nil {
|
||||
t.Fatal("Seed wrote into the rejected config dir")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSeedWritesFixtureAndInitializesRegistry covers the seeding happy path:
|
||||
// cache files land under the config dir, the registry initializes from them,
|
||||
// and both self-checks pass.
|
||||
func TestSeedWritesFixtureAndInitializesRegistry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, "config")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
if err := Seed(root); err != nil {
|
||||
t.Fatalf("Seed() error = %v, want nil", err)
|
||||
}
|
||||
for _, name := range []string{"remote_meta.json", "remote_meta.meta.json"} {
|
||||
if _, err := os.Stat(filepath.Join(configDir, "cache", name)); err != nil {
|
||||
t.Errorf("cache file %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if got := os.Getenv("LARKSUITE_CLI_REMOTE_META"); got != "off" {
|
||||
t.Errorf("LARKSUITE_CLI_REMOTE_META = %q, want off after seeding", got)
|
||||
}
|
||||
for _, service := range []string{"calendar", "im", "task"} {
|
||||
if _, ok := registry.ServiceTyped(service); !ok {
|
||||
t.Errorf("registry missing service %s after seeding", service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedPropagatesCacheSetupFailures pins that filesystem failures while
|
||||
// materializing the cache surface as errors instead of leaving the registry
|
||||
// silently unseeded. Each obstacle is a same-named file/directory in the
|
||||
// way, which fails on every platform without permission tricks.
|
||||
func TestSeedPropagatesCacheSetupFailures(t *testing.T) {
|
||||
seedWith := func(t *testing.T, prepare func(root, configDir string)) error {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, "config")
|
||||
prepare(root, configDir)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
return Seed(root)
|
||||
}
|
||||
|
||||
t.Run("cache dir creation fails", func(t *testing.T) {
|
||||
err := seedWith(t, func(root, configDir string) {
|
||||
// config is a regular file, so MkdirAll(config/cache) fails.
|
||||
if err := os.WriteFile(configDir, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Seed() error = nil, want cache dir creation failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixture write fails", func(t *testing.T) {
|
||||
err := seedWith(t, func(root, configDir string) {
|
||||
// remote_meta.json is a directory, so WriteFile fails.
|
||||
if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.json"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Seed() error = nil, want fixture write failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cache meta write fails", func(t *testing.T) {
|
||||
err := seedWith(t, func(root, configDir string) {
|
||||
// remote_meta.meta.json is a directory, so WriteFile fails.
|
||||
if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.meta.json"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Seed() error = nil, want cache meta write failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-registry-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
// A test that ran Init without a trailing resetInit can leave a background
|
||||
// refresh goroutine alive; removing the temp root while it writes would
|
||||
// let it recreate the directory after cleanup. Wait it out first.
|
||||
waitBackgroundRefresh()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package gitcmd provides Git process helpers for tests that use temporary
|
||||
// repositories.
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
maintenanceAutoDetach = "maintenance.autoDetach"
|
||||
gcAutoDetach = "gc.autoDetach"
|
||||
)
|
||||
|
||||
// Command creates a Git command whose automatic maintenance stays in the
|
||||
// command lifecycle, so temporary repository cleanup cannot race a detached
|
||||
// maintenance process.
|
||||
func Command(dir string, args ...string) *exec.Cmd {
|
||||
commandArgs := make([]string, 0, len(args)+4)
|
||||
commandArgs = append(commandArgs,
|
||||
"-c", maintenanceAutoDetach+"=false",
|
||||
"-c", gcAutoDetach+"=false",
|
||||
)
|
||||
commandArgs = append(commandArgs, args...)
|
||||
cmd := exec.Command("git", commandArgs...)
|
||||
cmd.Dir = dir
|
||||
return cmd
|
||||
}
|
||||
|
||||
// SetSynchronousMaintenanceEnv applies the same lifecycle contract to every
|
||||
// Git process started by the current test, including processes created through
|
||||
// production command runners. Tests using it must not run in parallel.
|
||||
func SetSynchronousMaintenanceEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
count := 0
|
||||
if value, ok := os.LookupEnv("GIT_CONFIG_COUNT"); ok {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 0 {
|
||||
t.Fatalf("invalid GIT_CONFIG_COUNT %q", value)
|
||||
}
|
||||
count = parsed
|
||||
}
|
||||
for _, key := range []string{maintenanceAutoDetach, gcAutoDetach} {
|
||||
index := strconv.Itoa(count)
|
||||
t.Setenv("GIT_CONFIG_KEY_"+index, key)
|
||||
t.Setenv("GIT_CONFIG_VALUE_"+index, "false")
|
||||
count++
|
||||
}
|
||||
t.Setenv("GIT_CONFIG_COUNT", strconv.Itoa(count))
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcmd
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommandDisablesDetachedMaintenance(t *testing.T) {
|
||||
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
|
||||
cmd := Command(t.TempDir(), "config", "--get", "--type=bool", key)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git config %s: %v\n%s", key, err, out)
|
||||
}
|
||||
if got := strings.TrimSpace(string(out)); got != "false" {
|
||||
t.Fatalf("%s = %q, want false", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSynchronousMaintenanceEnv(t *testing.T) {
|
||||
t.Setenv("GIT_CONFIG_COUNT", "1")
|
||||
t.Setenv("GIT_CONFIG_KEY_0", "user.name")
|
||||
t.Setenv("GIT_CONFIG_VALUE_0", "Existing Test User")
|
||||
SetSynchronousMaintenanceEnv(t)
|
||||
for key, want := range map[string]string{
|
||||
"user.name": "Existing Test User",
|
||||
maintenanceAutoDetach: "false",
|
||||
gcAutoDetach: "false",
|
||||
} {
|
||||
cmd := exec.Command("git", "config", "--get", "--type=bool", key)
|
||||
if key == "user.name" {
|
||||
cmd = exec.Command("git", "config", "--get", key)
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git config %s: %v\n%s", key, err, out)
|
||||
}
|
||||
if got := strings.TrimSpace(string(out)); got != want {
|
||||
t.Fatalf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,7 @@ func writeFixture(t *testing.T, files fixtureRepo) string {
|
||||
|
||||
func runGit(t *testing.T, root string, args ...string) string {
|
||||
t.Helper()
|
||||
commandArgs := []string{
|
||||
"-c", "maintenance.autoDetach=false",
|
||||
"-c", "gc.autoDetach=false",
|
||||
}
|
||||
commandArgs = append(commandArgs, args...)
|
||||
cmd := exec.Command("git", commandArgs...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = root
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -48,14 +43,6 @@ func runGit(t *testing.T, root string, args ...string) string {
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func TestRunGitDisablesDetachedMaintenance(t *testing.T) {
|
||||
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
|
||||
if got := runGit(t, t.TempDir(), "config", "--get", "--type=bool", key); got != "false" {
|
||||
t.Fatalf("%s = %q, want false", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSubtypeAllowlist_ExtractsTypedConstValues(t *testing.T) {
|
||||
root := writeFixture(t, fixtureRepo{
|
||||
"errs/subtypes.go": `package errs
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -1,16 +1,15 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.75",
|
||||
"version": "1.0.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.75",
|
||||
"version": "1.0.11",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
"riscv64"
|
||||
"arm64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.75",
|
||||
"version": "1.0.72",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/install.js",
|
||||
"release:check": "node scripts/release-preflight.js"
|
||||
"postinstall": "node scripts/install.js"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
|
||||
@@ -265,7 +265,10 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
const checksumsPath = path.join(dir, "checksums.txt");
|
||||
|
||||
if (!fs.existsSync(checksumsPath)) {
|
||||
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
|
||||
console.error(
|
||||
"[WARN] checksums.txt not found, skipping checksum verification"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(checksumsPath, "utf8");
|
||||
@@ -283,14 +286,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
|
||||
}
|
||||
|
||||
function verifyChecksum(archivePath, expectedHash) {
|
||||
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
|
||||
throw new Error("[SECURITY] Expected checksum is missing or invalid");
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
|
||||
throw new Error(
|
||||
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
|
||||
);
|
||||
}
|
||||
if (expectedHash === null) return;
|
||||
|
||||
// Stream the file to avoid loading the entire archive into memory.
|
||||
// Archives can be 10-100MB; streaming keeps RSS constant.
|
||||
|
||||
@@ -52,12 +52,11 @@ describe("getExpectedChecksum", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
|
||||
it("returns null when checksums.txt does not exist", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
|
||||
assert.throws(
|
||||
() => getExpectedChecksum("anything.tar.gz", dir),
|
||||
{ message: /^\[SECURITY\] checksums\.txt not found/ }
|
||||
);
|
||||
// No checksums.txt in dir
|
||||
const result = getExpectedChecksum("anything.tar.gz", dir);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it("skips malformed lines and still finds valid entry", () => {
|
||||
@@ -107,7 +106,7 @@ describe("verifyChecksum", () => {
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
it("accepts a valid uppercase 64-character hex hash", () => {
|
||||
it("matches case-insensitively", () => {
|
||||
const content = "case test";
|
||||
const filePath = makeTmpFile(content);
|
||||
const hash = sha256(content).toUpperCase();
|
||||
@@ -115,40 +114,6 @@ describe("verifyChecksum", () => {
|
||||
verifyChecksum(filePath, hash);
|
||||
});
|
||||
|
||||
for (const [name, expectedHash] of [
|
||||
["null", null],
|
||||
["empty", ""],
|
||||
["non-string", 123],
|
||||
]) {
|
||||
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, expectedHash),
|
||||
(err) => {
|
||||
assert.match(err.message, /^\[SECURITY\]/);
|
||||
assert.match(err.message, /Expected checksum is missing or invalid/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "abc123"),
|
||||
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY] format Error for a non-hex hash", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
() => verifyChecksum(filePath, "g".repeat(64)),
|
||||
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
|
||||
);
|
||||
});
|
||||
|
||||
it("throws [SECURITY]-prefixed Error on mismatch", () => {
|
||||
const filePath = makeTmpFile("real content");
|
||||
assert.throws(
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
|
||||
|
||||
function isStableVersion(value) {
|
||||
return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
|
||||
}
|
||||
|
||||
function releaseError(message, observed, hint) {
|
||||
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
|
||||
}
|
||||
|
||||
function validateReleasePreflight(packageJson, packageLockJson, tag) {
|
||||
const packageVersion = packageJson?.version;
|
||||
const lockVersion = packageLockJson?.version;
|
||||
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
|
||||
const observed = {
|
||||
packageVersion: packageVersion ?? null,
|
||||
lockVersion: lockVersion ?? null,
|
||||
lockRootVersion: lockRootVersion ?? null,
|
||||
tagVersion: null,
|
||||
};
|
||||
|
||||
for (const [field, value] of [
|
||||
["package.json.version", packageVersion],
|
||||
["package-lock.json.version", lockVersion],
|
||||
['package-lock.json.packages[""].version', lockRootVersion],
|
||||
]) {
|
||||
if (!isStableVersion(value)) {
|
||||
return releaseError(
|
||||
`${field} must be a stable release version in X.Y.Z form`,
|
||||
observed,
|
||||
"Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
|
||||
return releaseError(
|
||||
"Package version fields do not match",
|
||||
observed,
|
||||
"Synchronize package.json.version and both package-lock.json version fields.",
|
||||
);
|
||||
}
|
||||
|
||||
if (tag === undefined) {
|
||||
return { ok: true, data: observed };
|
||||
}
|
||||
if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
|
||||
return releaseError(
|
||||
"--tag must use the stable release form vX.Y.Z",
|
||||
{ ...observed, tag },
|
||||
`Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
|
||||
);
|
||||
}
|
||||
|
||||
const tagVersion = tag.slice(1);
|
||||
if (tagVersion !== packageVersion) {
|
||||
return releaseError(
|
||||
"Tag version does not match the package version",
|
||||
{ ...observed, tagVersion, tag },
|
||||
`Use --tag v${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
return { ok: true, data: { ...observed, tagVersion } };
|
||||
}
|
||||
|
||||
function writeResult(result) {
|
||||
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let tag;
|
||||
if (args.length === 2 && args[0] === "--tag") {
|
||||
tag = args[1];
|
||||
} else if (args.length !== 0) {
|
||||
writeResult(releaseError(
|
||||
"Expected no arguments or --tag vX.Y.Z",
|
||||
{ arguments: args },
|
||||
"Run release:check without arguments or pass exactly one --tag value.",
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
|
||||
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
|
||||
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
|
||||
} catch (error) {
|
||||
writeResult(releaseError(
|
||||
"Could not read release package metadata",
|
||||
{ reason: error.message },
|
||||
"Ensure package.json and package-lock.json exist and contain valid JSON.",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { validateReleasePreflight };
|
||||
|
||||
if (require.main === module) main();
|
||||
@@ -1,66 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { describe, it } = require("node:test");
|
||||
|
||||
const { validateReleasePreflight } = require("./release-preflight");
|
||||
|
||||
function metadata(version = "1.2.3") {
|
||||
return {
|
||||
packageJson: { version },
|
||||
packageLockJson: {
|
||||
version,
|
||||
packages: { "": { version } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assertRejected(result) {
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error.type, "release_preflight");
|
||||
assert.equal(typeof result.error.message, "string");
|
||||
}
|
||||
|
||||
describe("validateReleasePreflight", () => {
|
||||
it("accepts matching stable package, lock, and tag versions", () => {
|
||||
const { packageJson, packageLockJson } = metadata();
|
||||
|
||||
assert.deepEqual(
|
||||
validateReleasePreflight(packageJson, packageLockJson, "v1.2.3"),
|
||||
{
|
||||
ok: true,
|
||||
data: {
|
||||
packageVersion: "1.2.3",
|
||||
lockVersion: "1.2.3",
|
||||
lockRootVersion: "1.2.3",
|
||||
tagVersion: "1.2.3",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-stable or inconsistent package metadata", () => {
|
||||
const prerelease = metadata("1.2.3-beta.1");
|
||||
const topLevelMismatch = metadata();
|
||||
topLevelMismatch.packageLockJson.version = "1.2.4";
|
||||
const rootMismatch = metadata();
|
||||
rootMismatch.packageLockJson.packages[""].version = "1.2.4";
|
||||
|
||||
for (const { packageJson, packageLockJson } of [
|
||||
prerelease,
|
||||
topLevelMismatch,
|
||||
rootMismatch,
|
||||
]) {
|
||||
assertRejected(validateReleasePreflight(packageJson, packageLockJson));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an invalid or mismatched release tag", () => {
|
||||
const { packageJson, packageLockJson } = metadata();
|
||||
|
||||
for (const tag of ["1.2.3", "v1.2.3-beta.1", "v1.2.4"]) {
|
||||
assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,48 +3,49 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
# Read version from package.json
|
||||
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: could not read version from package.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="v${VERSION}"
|
||||
|
||||
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
|
||||
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Tag: ${TAG}"
|
||||
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
if [ "${CURRENT_BRANCH}" != "main" ]; then
|
||||
echo "Error: releases must be tagged from main; current branch is '${CURRENT_BRANCH}'." >&2
|
||||
# Check if tag already exists locally
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag ${TAG} already exists locally, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if tag already exists on remote
|
||||
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
|
||||
echo "Tag ${TAG} already exists on remote, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure package.json changes are committed before tagging
|
||||
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
|
||||
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git diff --quiet HEAD -- package.json package-lock.json; then
|
||||
echo "Error: package.json or package-lock.json has uncommitted changes. Please commit them before tagging." >&2
|
||||
# Ensure current branch is pushed to remote before tagging
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
LOCAL_SHA=$(git rev-parse HEAD)
|
||||
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
|
||||
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
|
||||
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin main
|
||||
# Create and push tag
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
FETCHED_MAIN_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
|
||||
if [ "${HEAD_SHA}" != "${FETCHED_MAIN_SHA}" ]; then
|
||||
echo "Error: HEAD must exactly match origin/main before tagging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||
echo "Error: local tag ${TAG} already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
|
||||
if [ -n "${REMOTE_TAG}" ]; then
|
||||
echo "Error: remote tag ${TAG} already exists." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git tag "${TAG}" "${HEAD_SHA}"
|
||||
git push origin "refs/tags/${TAG}"
|
||||
|
||||
echo "Successfully pushed tag ${TAG}"
|
||||
echo "Successfully created and pushed tag ${TAG}"
|
||||
|
||||
@@ -1,469 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const automationSkillDoc = "../../skills/lark-apps/references/lark-apps-automation.md"
|
||||
const localDevSkillDoc = "../../skills/lark-apps/references/lark-apps-local-dev.md"
|
||||
const larkAppsSkillDoc = "../../skills/lark-apps/SKILL.md"
|
||||
const releaseGetSkillDoc = "../../skills/lark-apps/references/lark-apps-release-get.md"
|
||||
|
||||
func readAutomationSkillDoc(t *testing.T) string {
|
||||
return readAppsSkillDoc(t, automationSkillDoc)
|
||||
}
|
||||
|
||||
func readLocalDevSkillDoc(t *testing.T) string {
|
||||
return readAppsSkillDoc(t, localDevSkillDoc)
|
||||
}
|
||||
|
||||
func readReleaseGetSkillDoc(t *testing.T) string {
|
||||
return readAppsSkillDoc(t, releaseGetSkillDoc)
|
||||
}
|
||||
|
||||
func readAppsSkillDoc(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read skill doc %s: %v", path, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func skillSection(t *testing.T, doc, heading string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(doc, heading)
|
||||
if start < 0 {
|
||||
t.Fatalf("missing skill section %q", heading)
|
||||
}
|
||||
rest := doc[start+len(heading):]
|
||||
if next := strings.Index(rest, "\n## "); next >= 0 {
|
||||
return rest[:next]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
func skillSubsection(t *testing.T, doc, heading string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(doc, heading)
|
||||
if start < 0 {
|
||||
t.Fatalf("missing skill subsection %q", heading)
|
||||
}
|
||||
rest := doc[start+len(heading):]
|
||||
end := len(rest)
|
||||
for _, marker := range []string{"\n### ", "\n## "} {
|
||||
if next := strings.Index(rest, marker); next >= 0 && next < end {
|
||||
end = next
|
||||
}
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
|
||||
func requireInOrder(t *testing.T, text string, tokens ...string) {
|
||||
t.Helper()
|
||||
offset := 0
|
||||
for _, token := range tokens {
|
||||
idx := strings.Index(text[offset:], token)
|
||||
if idx < 0 {
|
||||
t.Fatalf("missing %q after %q", token, text[:offset])
|
||||
}
|
||||
offset += idx + len(token)
|
||||
}
|
||||
}
|
||||
|
||||
func requireFirstOccurrencesInOrder(t *testing.T, text string, tokens ...string) {
|
||||
t.Helper()
|
||||
previous := -1
|
||||
for _, token := range tokens {
|
||||
idx := strings.Index(text, token)
|
||||
if idx < 0 {
|
||||
t.Fatalf("missing %q", token)
|
||||
}
|
||||
if idx <= previous {
|
||||
t.Fatalf("first %q at %d must follow the previous contract token at %d", token, idx, previous)
|
||||
}
|
||||
previous = idx
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_ChangedHandlerStartWaitsForThisRelease(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 实现或更新 handler 后发布并启动/测试")
|
||||
|
||||
requireInOrder(t, section,
|
||||
"仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler",
|
||||
"+automation-get",
|
||||
"记录发布前状态",
|
||||
"--name",
|
||||
"项目 guide",
|
||||
"按项目 guide 完成同名业务 handler 并本地验证。",
|
||||
"在 Git 已确认/预授权时 commit,然后执行",
|
||||
"git push origin sprint/default",
|
||||
"临时停用授权",
|
||||
"+automation-disable",
|
||||
"确认 disabled",
|
||||
"+release-create --branch sprint/default",
|
||||
"data.release_id",
|
||||
"+release-get",
|
||||
"data.status=finished",
|
||||
"仅启动",
|
||||
"+automation-enable",
|
||||
"+automation-get",
|
||||
"不制造 runtime probe",
|
||||
"测试",
|
||||
"运行时验证的操作级授权",
|
||||
"完成全部 preflight",
|
||||
"才执行 `+automation-enable`",
|
||||
"真实 runtime",
|
||||
"仅要求测试",
|
||||
"恢复到发布前状态",
|
||||
)
|
||||
requireFirstOccurrencesInOrder(t, section,
|
||||
"+automation-get",
|
||||
"git push origin sprint/default",
|
||||
"临时停用授权",
|
||||
"+automation-disable",
|
||||
"+release-create --branch sprint/default",
|
||||
"data.status=finished",
|
||||
"仅启动",
|
||||
)
|
||||
for _, boundary := range []string{
|
||||
"仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler,且用户要求把这次代码发布后启动或测试时,才使用此路径。",
|
||||
"按项目 guide 完成同名业务 handler 并本地验证。",
|
||||
"在 Git 已确认/预授权时 commit,然后执行 `git push origin sprint/default`。",
|
||||
"若该命令本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release(新代码未上线),原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled 后停止;若因超时等导致结果未知,保持 disabled,先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
|
||||
"只有 `data.status=finished` 才能继续;`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟。",
|
||||
"确认 `failed` 时报告发布失败,原本 enabled 的 trigger 仅在确认新代码未上线后恢复 enabled,原本 disabled 的保持 disabled。",
|
||||
"发布状态仍不确定时不得进入 enable、probe 或状态恢复分支。",
|
||||
"**仅启动**:取得持续启动授权后执行 `+automation-enable`,并用 `+automation-get` 确认 enabled;到此结束,不制造 runtime probe。",
|
||||
"**测试(含“启动并测试”)**:先按下节“运行时验证的操作级授权”完成全部 preflight",
|
||||
"若用户仅要求测试而不是持续启动,只在本轮 release 已 `finished` 且 probe 成功后恢复到发布前状态",
|
||||
"无论用户是仅测试还是启动并测试,probe 失败、结果不确定或 enable 后提前结束时,一律 `+automation-disable` 并回读 disabled",
|
||||
"不得把“发布前 enabled”当作失败后的恢复依据",
|
||||
"没有通用的 `automation-debug` 或 trigger 日志 shortcut。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("complete-start section must explain %q boundary", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_BindsTheExactNameAsUser(t *testing.T) {
|
||||
doc := readAutomationSkillDoc(t)
|
||||
for _, boundary := range []string{
|
||||
"全部操作需 `--as user`(AuthType: user)。",
|
||||
"当用户希望触发器实际执行业务代码时,先确认当前工作区是已初始化的应用项目,并读取其中与触发器任务匹配的 guide。",
|
||||
"`--name` 是应用内唯一的 trigger 定位键;代码侧绑定名称必须与它逐字相同。不得用 trigger ID 或方法名代替它。具体 handler 语法和接入方式以项目 guide 为准。",
|
||||
} {
|
||||
if !strings.Contains(doc, boundary) {
|
||||
t.Errorf("automation skill must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_RoutesAndDiagnosesUnfiredTriggers(t *testing.T) {
|
||||
doc := readAutomationSkillDoc(t)
|
||||
routeSection := skillSection(t, doc, "## 何时用本 skill(路由锚点)")
|
||||
errorSection := skillSection(t, doc, "## 常见错误与决策场景")
|
||||
|
||||
if !strings.Contains(routeSection, "「触发器没反应 / enable 了不触发 / 为什么没执行 / 验证一下触发器」→ 先按「未触发时的诊断顺序」诊断;对 UPSERT 和 feishu-approval 仅验证配置边界,不承诺 handler 或 live 验证。") {
|
||||
t.Error("routing anchors must direct unfired triggers to the bounded diagnostic flow")
|
||||
}
|
||||
if !strings.Contains(errorSection, "已证实的 cron、webhook、record-change(INSERT/UPDATE/DELETE)按「未触发时的诊断顺序」排查;UPSERT 和 feishu-approval 仅核对配置边界,不承诺 handler 或 live 验证。") {
|
||||
t.Error("error table must preserve the bounded unfired-trigger diagnostic flow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_ConfigurationStopsDisabled(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅创建/配置触发器")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"用 `+automation-create` 创建,并省略 `--status` 或显式传 `disabled`,然后报告 name 和 disabled 状态。",
|
||||
"不要传 `--status enabled`,也不要写 handler、commit/push、release 或 enable;更不能把创建 API 成功称为“可运行”。",
|
||||
"默认 disabled 是这个意图的终点,不是稍后自动 enable 的待办。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("configuration-only section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_EnableExistingTriggerDoesNotPublish(t *testing.T) {
|
||||
doc := readAutomationSkillDoc(t)
|
||||
section := skillSubsection(t, doc, "### 仅启用已有 disabled trigger")
|
||||
routeSection := skillSection(t, doc, "## 何时用本 skill(路由锚点)")
|
||||
|
||||
requireInOrder(t, section,
|
||||
"用户只要求启用已存在且 disabled 的 trigger",
|
||||
"+automation-get",
|
||||
"+release-list --status finished --page-size 1",
|
||||
"已完成线上 release",
|
||||
"当前线上应用",
|
||||
"不能证明该 trigger name 已绑定 handler",
|
||||
"+automation-enable",
|
||||
"+automation-get",
|
||||
"不得修改 handler、commit/push 或 release",
|
||||
"对 UPSERT 或 feishu-approval 只改变配置状态",
|
||||
)
|
||||
if !strings.Contains(section, "未发布时不得自动创建 release,也不得声称 trigger 已开始实际运行") {
|
||||
t.Error("enable-only flow must distinguish configuration enablement from a published runtime")
|
||||
}
|
||||
if !strings.Contains(section, "即使存在 finished release,也只能把 enable 报告为配置激活") {
|
||||
t.Error("enable-only flow must not infer handler provenance from app release history")
|
||||
}
|
||||
if strings.Contains(section, "apps +get") || strings.Contains(section, "`is_published`") {
|
||||
t.Error("enable-only flow must use finished release history instead of an optional app detail field")
|
||||
}
|
||||
for _, forbidden := range []string{"git push", "+release-create"} {
|
||||
if strings.Contains(section, forbidden) {
|
||||
t.Errorf("enable-only flow must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(routeSection, "「启用 / 启动已有 trigger」→ 先核对现有状态;只启用时不要修改源码或发布应用。") {
|
||||
t.Error("routing anchors must keep existing-trigger enablement separate from code release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_TestExistingTriggerDoesNotPublish(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 测试已有线上 trigger(不改代码)")
|
||||
|
||||
requireInOrder(t, section,
|
||||
"用户要求测试已经发布的 trigger",
|
||||
"+automation-get",
|
||||
"+release-list --status finished --page-size 1",
|
||||
"当前线上代码",
|
||||
"不得为测试自动修改源码、commit/push 或 release",
|
||||
"在任何临时 enable 之前完成",
|
||||
"测试请求已明确包含临时 enable,或另行取得 enable 授权",
|
||||
"运行时验证的操作级授权",
|
||||
"无论 probe 成功、失败、结果不确定,还是临时 enable 后提前结束或中断,最终都必须 `+automation-disable` 并回读 disabled",
|
||||
)
|
||||
for _, forbidden := range []string{"git push", "+release-create"} {
|
||||
if strings.Contains(section, forbidden) {
|
||||
t.Errorf("existing-trigger test flow must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_HandlerOnlyStopsBeforeRelease(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler(不发布/不启用)")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"创建或定位已明确 name 的 disabled trigger,读取项目 guide,按其要求实现同名业务 handler,完成本地验证。",
|
||||
"只在既有 Git 确认或预授权下 commit/push;停止在 `+release-create` 和 `+automation-enable` 之前。",
|
||||
"用户没有明确“发布好”时,先问,不能默认把完整应用上线。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("handler-only section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_HandlerOnlyExcludesUnverifiedRuntimeTypes(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler(不发布/不启用)")
|
||||
|
||||
if !strings.Contains(section, "仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。") {
|
||||
t.Error("handler-only flow must exclude UPSERT and feishu-approval without a verified runtime contract")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_PublishedHandlerStaysDisabled(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 把 handler 发布好,但先不要启动")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。",
|
||||
"先用 `+automation-get` 定位;不存在时用 `+automation-create` 创建同名 disabled trigger,再次回读确认。",
|
||||
"已存在时记录它是否 enabled。",
|
||||
"若 trigger 已 enabled,先说明发布前必须临时停用以及可能造成的运行中断,并取得这次临时停用授权;未获授权时停止在发布前。",
|
||||
"取得授权后,在发布前执行 `+automation-disable`,并再次用 `+automation-get` 确认 disabled。",
|
||||
"按项目 guide 完成同名业务 handler 并本地验证后,commit、`git push origin sprint/default`。",
|
||||
"随后发布完整应用:",
|
||||
"若 `+release-create` 本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release(新代码未上线),原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled,然后停止;若因超时等导致创建结果未知,保持 disabled,先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
|
||||
"取得 `data.release_id` 后,对**这一轮** ID 调用 `+release-get`:`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时且状态仍不确定时报告 `release_id` 和当前 status,并保持 disabled;只有 `data.status=finished` 才算完成。",
|
||||
"确认 `failed` 且新代码未上线时,原本 enabled 的 trigger 恢复 enabled 并回读,原本 disabled 的保持 disabled。",
|
||||
"release 是整个应用上线,可能影响既有线上功能;未获得启动或测试授权时,finished 后始终保持 disabled,不执行 `+automation-enable`。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("publish-without-start section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
requireFirstOccurrencesInOrder(t, section,
|
||||
"+automation-get",
|
||||
"git push origin sprint/default",
|
||||
"临时停用授权",
|
||||
"+automation-disable",
|
||||
"+release-create",
|
||||
)
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_UPSERTAndApprovalStayConfigurationOnly(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### UPSERT 与飞书审批边界")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"record-change 的 UPSERT 可创建 disabled 配置,但当前没有已证实的运行时代码契约;不得静默按 UPDATE 处理,也不得承诺 handler 或 live 验证。",
|
||||
"feishu-approval 可创建 disabled 配置,并读取或更新 `event_type`、对应 status 和可选 `approval_code`。",
|
||||
"当前没有已证实的运行时 handler 契约或实际投递验证;不要把 enable 或审批 API 成功称为业务代码已执行。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("UPSERT/approval boundary section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_RuntimeProbeRequiresOperationScope(t *testing.T) {
|
||||
section := skillSubsection(t, readAutomationSkillDoc(t), "### 运行时验证的操作级授权")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"启用 trigger 的授权不等于制造 runtime 事件的授权,测试授权也不等于任意数据库写入授权。",
|
||||
"record-change 在执行任何 DML 前,必须明确并取得覆盖以下作用域的授权",
|
||||
"环境、表、操作、精确测试记录或筛选条件、payload、预期结果和清理方式",
|
||||
"优先使用专用测试记录",
|
||||
"`DELETE`",
|
||||
"[lark-apps-db-execute.md](lark-apps-db-execute.md)",
|
||||
"先 `SELECT count(*)`、执行 `--dry-run`",
|
||||
"取得针对该删除目标的明确授权",
|
||||
"+automation-list --trigger-type record-change --all",
|
||||
"同一环境、表和操作可能命中的其他 enabled trigger",
|
||||
"聚合业务影响",
|
||||
"恢复 UPDATE 或清理 INSERT 也可能再次触发自动化",
|
||||
"缺少安全、已授权且可清理的事件入口时,记录 blocked",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("runtime probe section must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomationSkillContract_UsesResolvableSharedSkillLink(t *testing.T) {
|
||||
doc := readAutomationSkillDoc(t)
|
||||
|
||||
if strings.Contains(doc, "](../lark-shared/SKILL.md)") {
|
||||
t.Error("automation reference must not resolve lark-shared inside the lark-apps directory")
|
||||
}
|
||||
if !strings.Contains(doc, "](../../lark-shared/SKILL.md)") {
|
||||
t.Error("automation reference must link to the sibling lark-shared skill")
|
||||
}
|
||||
sharedSkillDoc := filepath.Clean(filepath.Join(filepath.Dir(automationSkillDoc), "../../lark-shared/SKILL.md"))
|
||||
if _, err := os.Stat(sharedSkillDoc); err != nil {
|
||||
t.Fatalf("automation reference target %s must exist: %v", sharedSkillDoc, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSkillContract_AllSharedSkillLinksResolve(t *testing.T) {
|
||||
docs := []string{larkAppsSkillDoc}
|
||||
references, err := filepath.Glob("../../skills/lark-apps/references/*.md")
|
||||
if err != nil {
|
||||
t.Fatalf("glob lark-apps references: %v", err)
|
||||
}
|
||||
docs = append(docs, references...)
|
||||
sharedLink := regexp.MustCompile(`\]\(([^)]+lark-shared/SKILL\.md)\)`)
|
||||
|
||||
for _, docPath := range docs {
|
||||
doc := readAppsSkillDoc(t, docPath)
|
||||
for _, match := range sharedLink.FindAllStringSubmatch(doc, -1) {
|
||||
target := filepath.Clean(filepath.Join(filepath.Dir(docPath), match[1]))
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Errorf("%s shared-skill link %q resolves to missing target %s: %v", docPath, match[1], target, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDevSkillContract_UsesProjectGuideWithoutSyncInternals(t *testing.T) {
|
||||
section := skillSection(t, readLocalDevSkillDoc(t), "## Trigger guide 的项目边界")
|
||||
|
||||
for _, boundary := range []string{
|
||||
"先查看工作区 `.agents/skills/`,读取与自动化任务匹配的 `trigger-guide`。",
|
||||
"文件缺失或不能覆盖当前任务时,报告项目缺少可用的领域 guide;不要在本 lark-cli reference 中猜测安装命令、版本或包内目录。",
|
||||
} {
|
||||
if !strings.Contains(section, boundary) {
|
||||
t.Errorf("trigger-guide boundary section must explain %q", boundary)
|
||||
}
|
||||
}
|
||||
for _, implementationShape := range []string{
|
||||
"npx ", "skills sync", "data.", "skills_", "_CACHE_DIR", "nestjs-",
|
||||
"@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
|
||||
} {
|
||||
if strings.Contains(section, implementationShape) {
|
||||
t.Errorf("local-dev skill must not expose project-sync implementation shape %q", implementationShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsSkillContract_DoesNotExposeSteeringImplementation(t *testing.T) {
|
||||
for name, doc := range map[string]string{
|
||||
"automation": readAutomationSkillDoc(t),
|
||||
"local-dev": readLocalDevSkillDoc(t),
|
||||
} {
|
||||
for _, implementationShape := range []string{
|
||||
"npx ", "skills sync", "@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
|
||||
} {
|
||||
if strings.Contains(doc, implementationShape) {
|
||||
t.Errorf("%s skill must not expose project-sync implementation shape %q", name, implementationShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDevSkillContract_UsesEnvironmentAndDefersEnableToAutomationSOP(t *testing.T) {
|
||||
doc := readLocalDevSkillDoc(t)
|
||||
releaseSection := skillSection(t, doc, "## 改完代码后部署上线")
|
||||
for _, legacy := range []string{"--env dev", "--env online"} {
|
||||
if strings.Contains(doc, legacy) {
|
||||
t.Errorf("local-dev skill must not recommend legacy %q", legacy)
|
||||
}
|
||||
}
|
||||
for _, boundary := range []string{
|
||||
"`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时仍未完成时停止本轮轮询、报告 `release_id` 和当前 status。",
|
||||
"若本次改动包含自动化 handler,在执行本节通用 commit/push/release 序列前就转到 [automation SOP](lark-apps-automation.md) 的匹配路径,由该 SOP 负责完整的状态门禁、commit/push、release 和可选 enable/test;不要先按本节发布再补 trigger 状态检查。",
|
||||
"用户只要求启用已有 trigger 时,转到 [automation SOP 的「仅启用已有 disabled trigger」路径](lark-apps-automation.md#仅启用已有-disabled-trigger);不得因 enable 反向修改 handler、commit/push 或 release。",
|
||||
"使用 `--environment dev|online`,不要使用旧的 `--env`。只有确认应用已开启多环境时才引导 `--environment dev`;单环境应用省略 `--environment`(服务端选 online)或显式传 `--environment online`。",
|
||||
} {
|
||||
if !strings.Contains(doc, boundary) {
|
||||
t.Errorf("local-dev skill must preserve %q", boundary)
|
||||
}
|
||||
}
|
||||
routeIndex := strings.Index(releaseSection, "若本次改动包含自动化 handler")
|
||||
releaseIndex := strings.Index(releaseSection, "+release-create")
|
||||
if routeIndex < 0 || releaseIndex < 0 || routeIndex >= releaseIndex {
|
||||
t.Error("automation routing must appear before the generic release sequence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDevSkillContract_DoesNotRequireOnlineURL(t *testing.T) {
|
||||
section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
|
||||
|
||||
if strings.Contains(section, "`finished` 成功时该命令输出已含 `online_url`") {
|
||||
t.Error("release guidance must not claim every finished release includes online_url")
|
||||
}
|
||||
if !strings.Contains(section, "若返回 `online_url`,可直接使用;未返回时不要编造链接。") {
|
||||
t.Error("release guidance must explain that online_url is optional")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDevSkillContract_TreatsErrorLogsAsOptional(t *testing.T) {
|
||||
section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
|
||||
|
||||
if !strings.Contains(section, "`failed` 时若返回非空 `error_logs`,据此给出失败原因;否则只报告 `release_id` 和当前 status,不要编造原因") {
|
||||
t.Error("release guidance must not promise error_logs on every failed release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseSkillContract_TreatsOptionalOutputAsOptional(t *testing.T) {
|
||||
releaseGet := readReleaseGetSkillDoc(t)
|
||||
for _, boundary := range []string{
|
||||
"`finished` 后才可能有 `online_url`。",
|
||||
"若输出含 `online_url`,直接读取它作为本轮发布的线上访问链接;未返回时只报告发布完成,不要编造链接。",
|
||||
"若输出含 `error_logs`(`step`/`error_log`),据此向用户转述关键失败步骤和可行动修复;未返回时不要编造失败原因。",
|
||||
} {
|
||||
if !strings.Contains(releaseGet, boundary) {
|
||||
t.Errorf("release-get skill must preserve optional-output boundary %q", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
)
|
||||
|
||||
func appsValidationError(format string, args ...any) *errs.ValidationError {
|
||||
@@ -73,3 +74,32 @@ func appsInputPathEntryError(path string, err error) error {
|
||||
func appsFileIOError(err error, format string, args ...any) *errs.InternalError {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// enrichHTMLPublishAPIError adapts a typed failure from the HTML publish
|
||||
// endpoint: refines endpoint-scoped business codes, prefixes the message with
|
||||
// command context, and attaches endpoint-specific recovery hints. A
|
||||
// still-untyped error is lifted at the SDK boundary instead.
|
||||
func enrichHTMLPublishAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return client.WrapDoAPIError(err)
|
||||
}
|
||||
// The HTML publish business codes (90001/90002) are scoped to this
|
||||
// endpoint, not service-global, so their subtype classification lives
|
||||
// here instead of the global errclass code table. Only an
|
||||
// otherwise-unclassified API error is refined; a stronger upstream
|
||||
// classification is never overridden.
|
||||
if p.Category == errs.CategoryAPI && p.Subtype == errs.SubtypeUnknown && p.Code == errCodeAppNotFound {
|
||||
p.Subtype = errs.SubtypeNotFound
|
||||
}
|
||||
if p.Message != "" {
|
||||
p.Message = "html-publish failed: " + p.Message
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(p.Code); hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -57,3 +57,57 @@ func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) {
|
||||
t.Fatalf("cause chain not preserved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_LiftsUntypedBoundaryError(t *testing.T) {
|
||||
err := enrichHTMLPublishAPIError(errors.New("connection reset by peer"))
|
||||
|
||||
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
|
||||
if problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_PreservesClassificationAndAddsHint(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "build failed").
|
||||
WithCode(errCodeBuildFailed).
|
||||
WithLogID("logid-build-failed")
|
||||
|
||||
got := enrichHTMLPublishAPIError(err)
|
||||
if got != err {
|
||||
t.Fatalf("typed error should be enriched in place")
|
||||
}
|
||||
problem := requireAppsAPIProblem(t, got)
|
||||
if problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if problem.Code != errCodeBuildFailed {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
|
||||
}
|
||||
if problem.LogID != "logid-build-failed" {
|
||||
t.Fatalf("log_id = %q, want preserved", problem.LogID)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "html-publish failed") {
|
||||
t.Fatalf("message = %q, want html-publish context", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected known-code recovery hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_ClassifiesAppNotFoundLocally(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeUnknown, "app not found").WithCode(errCodeAppNotFound)
|
||||
|
||||
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
|
||||
if problem.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichHTMLPublishAPIError_KeepsStrongerClassification(t *testing.T) {
|
||||
err := errs.NewAPIError(errs.SubtypeRateLimit, "throttled").WithCode(errCodeAppNotFound)
|
||||
|
||||
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
|
||||
if problem.Subtype != errs.SubtypeRateLimit {
|
||||
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeRateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,10 @@ import (
|
||||
var AppsGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+get",
|
||||
Description: "Get a single app's detail by app ID or meta token (returns app_type, name, description, publish status, etc.)",
|
||||
Description: "Get a single app's detail by app ID (returns app_type, name, description, publish status, etc.)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +get --app-id <app_id>",
|
||||
"Example: lark-cli apps +get --app-id <meta_token>",
|
||||
"Example: lark-cli apps +get --app-id <app_id> --dry-run",
|
||||
"Tip: extract app type with --jq '.data.app.app_type'",
|
||||
},
|
||||
@@ -29,7 +28,7 @@ var AppsGet = common.Shortcut{
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID or meta token", Required: true},
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
@@ -41,7 +40,7 @@ var AppsGet = common.Shortcut{
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Get app detail (returns app_id, meta_token, app_type, name, description, icon_url, created_at, updated_at, is_published)")
|
||||
Desc("Get app detail (returns app_id, app_type, name, description, icon_url, created_at, updated_at, is_published)")
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
@@ -55,9 +54,6 @@ var AppsGet = common.Shortcut{
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "app_id: %v\n", app["app_id"])
|
||||
if mt, ok := app["meta_token"].(string); ok && mt != "" {
|
||||
fmt.Fprintf(w, "meta_token: %s\n", mt)
|
||||
}
|
||||
fmt.Fprintf(w, "app_type: %v\n", app["app_type"])
|
||||
fmt.Fprintf(w, "name: %v\n", app["name"])
|
||||
if desc, ok := app["description"].(string); ok && desc != "" {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -37,13 +38,9 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
{Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / .aws/credentials / etc. in the publish payload)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if err := validateRealAppID(appID); err != nil {
|
||||
return err
|
||||
}
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
if path == "" {
|
||||
return appsValidationParamError("--path", "--path is required")
|
||||
@@ -76,11 +73,9 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
path := strings.TrimSpace(rctx.Str("path"))
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Pack tar.gz → GET pre_release for TOS upload URL → PUT tar.gz to TOS → POST release-create with tos_path; returns release_id")
|
||||
dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
PUT("<presigned_upload_url> (from pre_release response)").
|
||||
POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))).
|
||||
Body(map[string]string{"tos_path": "<from pre_release response>"})
|
||||
dry.Desc("Pack tar.gz and publish HTML app (actual API path determined at runtime by app type; returns url or release_id)")
|
||||
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Set("content_type", "multipart/form-data")
|
||||
|
||||
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
@@ -128,7 +123,16 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
Path: strings.TrimSpace(rctx.Str("path")),
|
||||
}
|
||||
|
||||
out, err := runHTMLPublishTOS(ctx, rctx, spec)
|
||||
appType := queryAppType(ctx, rctx, spec.AppID)
|
||||
|
||||
var out map[string]interface{}
|
||||
var err error
|
||||
if appType == "modern_html" {
|
||||
out, err = runHTMLPublishTOS(ctx, rctx, spec)
|
||||
} else {
|
||||
client := appsHTMLPublishAPI{runtime: rctx}
|
||||
out, err = runHTMLPublish(ctx, rctx.FileIO(), client, spec)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -260,7 +264,25 @@ func prepareHTMLPublishTarball(fio fileio.FileIO, path string) (*htmlPublishTarb
|
||||
return tarball, nil
|
||||
}
|
||||
|
||||
// runHTMLPublishTOS handles the publish path: validate → tar.gz →
|
||||
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
tarball, err := prepareHTMLPublishTarball(fio, spec.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
|
||||
if err != nil {
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{}
|
||||
if resp.URL != "" {
|
||||
out["url"] = resp.URL
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runHTMLPublishTOS handles the modern_html publish path: validate → tar.gz →
|
||||
// call pre_release to get TOS upload URL → upload tar.gz to TOS → return
|
||||
// tos_path for +release-create --tos-path.
|
||||
func runHTMLPublishTOS(ctx context.Context, rctx *common.RuntimeContext, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -22,6 +23,20 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type fakeAppsHTMLPublishClient struct {
|
||||
resp *htmlPublishResponse
|
||||
err error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeAppsHTMLPublishClient) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
|
||||
f.calls = append(f.calls, appID)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.resp, nil
|
||||
}
|
||||
|
||||
func writeAppsSampleSite(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -31,19 +46,71 @@ func writeAppsSampleSite(t *testing.T) string {
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_PathNotFound(t *testing.T) {
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), "/nonexistent")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
func TestRunHTMLPublish_HappyPath(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
fake := &fakeAppsHTMLPublishClient{
|
||||
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
|
||||
}
|
||||
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if out["url"] != "https://miaoda/app_x" {
|
||||
t.Fatalf("url=%v", out["url"])
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0] != "app_x" {
|
||||
t.Fatalf("calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
|
||||
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
|
||||
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步" (legacy html path only):
|
||||
// envelope 只含 url,未来若有人加 status / release_id 字段会被这个测试拦截。
|
||||
site := writeAppsSampleSite(t)
|
||||
fake := &fakeAppsHTMLPublishClient{
|
||||
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
|
||||
}
|
||||
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("envelope should only contain 'url', got %d keys: %v", len(out), out)
|
||||
}
|
||||
if _, ok := out["url"]; !ok {
|
||||
t.Fatalf("envelope missing 'url': %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_ClientErrorPropagated(t *testing.T) {
|
||||
site := writeAppsSampleSite(t)
|
||||
wantErr := errors.New("server timeout")
|
||||
fake := &fakeAppsHTMLPublishClient{err: wantErr}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_PathNotFound(t *testing.T) {
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: "/nonexistent"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client should not be called when path invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
|
||||
// 目录形态:缺 index.html 应该被拦
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for missing index.html")
|
||||
}
|
||||
@@ -54,9 +121,13 @@ func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client should not be called when index.html missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
|
||||
func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
|
||||
// 目录含 index.html 应该正常走完
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
@@ -64,49 +135,57 @@ func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "extra.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
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("err=%v", err)
|
||||
}
|
||||
if tarball == nil || tarball.Size == 0 {
|
||||
t.Fatalf("expected non-empty tarball")
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called when index.html present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||||
func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
|
||||
// 单文件形态:文件名不是 index.html 也要拦
|
||||
dir := t.TempDir()
|
||||
single := filepath.Join(dir, "foo.html")
|
||||
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), single)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single})
|
||||
if err == nil {
|
||||
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
|
||||
}
|
||||
requireAppsValidationProblem(t, err)
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when index.html missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_SingleFileNamedIndexPasses(t *testing.T) {
|
||||
func TestRunHTMLPublish_SingleFileNamedIndexPasses(t *testing.T) {
|
||||
// 单文件形态:文件名恰好就是 index.html → 放行
|
||||
dir := t.TempDir()
|
||||
single := filepath.Join(dir, "index.html")
|
||||
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), single)
|
||||
if err != nil {
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if tarball == nil || tarball.Size == 0 {
|
||||
t.Fatalf("expected non-empty tarball")
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called for single index.html")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
|
||||
func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
|
||||
// 把上限调到 100 字节验证拦截,defer 恢复原值避免污染其它测试。
|
||||
orig := maxHTMLPublishTarballBytes
|
||||
maxHTMLPublishTarballBytes = 100
|
||||
defer func() { maxHTMLPublishTarballBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
// 写 index.html(满足新加的 index 校验)+ 大文件超 100 字节上限。
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
@@ -115,7 +194,8 @@ func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected oversize error")
|
||||
}
|
||||
@@ -126,6 +206,9 @@ func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client should not be called when tarball oversize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHTMLPublishTarballBytes_Default(t *testing.T) {
|
||||
@@ -181,17 +264,8 @@ func TestAppsHTMLPublish_DryRunPrintsManifest(t *testing.T) {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/pre_release") {
|
||||
t.Fatalf("dry-run missing pre_release endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "presigned_upload_url") {
|
||||
t.Fatalf("dry-run missing TOS PUT step: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/releases") {
|
||||
t.Fatalf("dry-run missing release-create endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "tos_path") {
|
||||
t.Fatalf("dry-run missing tos_path in release-create body: %s", got)
|
||||
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code") {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "index.html") {
|
||||
t.Fatalf("dry-run missing file list: %s", got)
|
||||
@@ -426,7 +500,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
|
||||
appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected raw-size cap to fire")
|
||||
}
|
||||
@@ -434,6 +510,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") {
|
||||
t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when raw cap hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeHTMLFiles(t *testing.T) {
|
||||
@@ -476,7 +555,8 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected per-file oversize error")
|
||||
}
|
||||
@@ -487,9 +567,13 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||||
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 TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
@@ -501,12 +585,12 @@ func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
|
||||
if err != nil {
|
||||
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 tarball == nil || tarball.Size == 0 {
|
||||
t.Fatalf("expected non-empty tarball")
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,18 +74,15 @@ type appTypePolicy struct {
|
||||
// skipSkillsSync skips the conditional `npx ... skills sync --local` step on
|
||||
// the non-empty (`app sync`) scaffold path.
|
||||
skipSkillsSync bool
|
||||
// skipAppSync skips `npx ... app sync` on the non-empty repo path.
|
||||
skipAppSync bool
|
||||
}
|
||||
|
||||
// appTypePolicies maps an app_type to its +init control strategy. Types absent
|
||||
// from the map get the zero-value policy (install runs, env is pulled, skills
|
||||
// are synced).
|
||||
var appTypePolicies = map[string]appTypePolicy{
|
||||
// modern_html / html are static HTML sites: no dependencies to install,
|
||||
// no startup env vars to pull, no steering skills to sync, and no app sync.
|
||||
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
|
||||
"html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
|
||||
// modern_html is a static HTML site: no dependencies to install, no startup
|
||||
// env vars to pull, and no steering skills to sync.
|
||||
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true},
|
||||
}
|
||||
|
||||
// policyForAppType returns the +init control strategy for appType. Unlisted
|
||||
@@ -125,13 +122,9 @@ var AppsInit = common.Shortcut{
|
||||
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if err := validateRealAppID(appID); err != nil {
|
||||
return err
|
||||
}
|
||||
if sp := strings.TrimSpace(rctx.Str("source-path")); sp != "" {
|
||||
if err := charcheck.RejectControlChars(sp, "--source-path"); err != nil {
|
||||
return appsValidationParamError("--source-path", "%v", err).WithCause(err)
|
||||
@@ -341,19 +334,11 @@ func ensureMetaAppID(dir, appID string) error {
|
||||
// each is not already resolvable from local/global/system config, so a
|
||||
// developer's existing identity is never overwritten. Each key is handled
|
||||
// independently (a machine with only user.name set still gets a default email).
|
||||
func ensureGitIdentity(ctx context.Context, dir, authorName, authorEmail string) error {
|
||||
name := strings.TrimSpace(authorName)
|
||||
if name == "" {
|
||||
name = defaultGitUserName
|
||||
}
|
||||
email := strings.TrimSpace(authorEmail)
|
||||
if email == "" {
|
||||
email = defaultGitUserEmail
|
||||
}
|
||||
if err := ensureGitConfigValue(ctx, dir, "user.name", name); err != nil {
|
||||
func ensureGitIdentity(ctx context.Context, dir string) error {
|
||||
if err := ensureGitConfigValue(ctx, dir, "user.name", defaultGitUserName); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureGitConfigValue(ctx, dir, "user.email", email)
|
||||
return ensureGitConfigValue(ctx, dir, "user.email", defaultGitUserEmail)
|
||||
}
|
||||
|
||||
// ensureGitConfigValue sets <key>=fallback in the repo-local git config when key
|
||||
@@ -415,16 +400,13 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
|
||||
}
|
||||
return scaffoldKindInit, nil
|
||||
}
|
||||
policy := policyForAppType(appType)
|
||||
if !policy.skipAppSync {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
if err := ensureMetaAppID(dir, appID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !policy.skipSkillsSync && !hasSteeringSkills(dir) {
|
||||
if !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
|
||||
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
|
||||
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
@@ -454,38 +436,26 @@ func scaffoldInitArgs(appType, appID, sourcePath string) []string {
|
||||
return base
|
||||
}
|
||||
|
||||
// credentialInitResult holds the fields parsed from +git-credential-init output.
|
||||
type credentialInitResult struct {
|
||||
RepositoryURL string
|
||||
CommitAuthorName string
|
||||
CommitAuthorEmail string
|
||||
}
|
||||
|
||||
// parseCredentialInitEnvelope extracts fields from a +git-credential-init JSON
|
||||
// envelope ({"ok":true,"data":{"repository_url":"...","commit_author_name":"...","commit_author_email":"..."}}).
|
||||
func parseCredentialInitEnvelope(stdout string) (credentialInitResult, error) {
|
||||
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
|
||||
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
|
||||
// matches the contract emitted by `apps +git-credential-init`.
|
||||
func parseRepoURLFromEnvelope(stdout string) (string, error) {
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
CommitAuthorName string `json:"commit_author_name"`
|
||||
CommitAuthorEmail string `json:"commit_author_email"`
|
||||
RepositoryURL string `json:"repository_url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
|
||||
return credentialInitResult{}, appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
|
||||
return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init reported failure")
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
|
||||
}
|
||||
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
|
||||
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
|
||||
return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
|
||||
}
|
||||
return credentialInitResult{
|
||||
RepositoryURL: env.Data.RepositoryURL,
|
||||
CommitAuthorName: env.Data.CommitAuthorName,
|
||||
CommitAuthorEmail: env.Data.CommitAuthorEmail,
|
||||
}, nil
|
||||
return env.Data.RepositoryURL, nil
|
||||
}
|
||||
|
||||
// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success
|
||||
@@ -557,10 +527,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
appType, err := queryAppType(ctx, rctx, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appType := queryAppType(ctx, rctx, appID)
|
||||
policy := policyForAppType(appType)
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
@@ -628,16 +595,16 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
initLogf(rctx, "Issuing repository credentials for %s...", appID)
|
||||
cred, err := issueCredentials(ctx, rctx, appID)
|
||||
repoURL, err := issueCredentials(ctx, rctx, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRepoURLScheme(cred.RepositoryURL); err != nil {
|
||||
if err := validateRepoURLScheme(repoURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
initLogf(rctx, "Cloning into %s...", dir)
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", cred.RepositoryURL, dir); err != nil {
|
||||
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
|
||||
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
|
||||
}
|
||||
initLogf(rctx, "Checking out %s...", defaultInitBranch)
|
||||
@@ -645,10 +612,9 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
|
||||
}
|
||||
|
||||
// Ensure a committer identity exists before the scaffold commit. Uses the
|
||||
// author name/email from +git-credential-init when available; falls back
|
||||
// to lark-cli-bot defaults when the server does not provide them.
|
||||
if err := ensureGitIdentity(ctx, dir, cred.CommitAuthorName, cred.CommitAuthorEmail); err != nil {
|
||||
// Ensure a committer identity exists before the scaffold commit; only sets
|
||||
// repo-local defaults when none is configured (existing identity is kept).
|
||||
if err := ensureGitIdentity(ctx, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -677,7 +643,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
|
||||
out := map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"repository_url": redactURLCredentials(cred.RepositoryURL),
|
||||
"repository_url": redactURLCredentials(repoURL),
|
||||
"branch": defaultInitBranch,
|
||||
"clone_path": dir,
|
||||
"scaffold": scaffold,
|
||||
@@ -755,10 +721,10 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
|
||||
|
||||
// issueCredentials runs `<self> apps +git-credential-init --app-id <id> --format json`
|
||||
// and returns the repo_url it reports. Forwards --as when set.
|
||||
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (credentialInitResult, error) {
|
||||
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return credentialInitResult{}, errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
|
||||
}
|
||||
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
|
||||
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
|
||||
@@ -766,11 +732,11 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
|
||||
}
|
||||
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
|
||||
if err != nil {
|
||||
return credentialInitResult{}, appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
|
||||
return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
|
||||
WithHint("ensure apps +git-credential-init is available and you are logged in").
|
||||
WithCause(err)
|
||||
}
|
||||
return parseCredentialInitEnvelope(stdout)
|
||||
return parseRepoURLFromEnvelope(stdout)
|
||||
}
|
||||
|
||||
// commitAndPushIfDirty commits and pushes only when the working tree has
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/testutil/gitcmd"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -111,24 +110,18 @@ func TestDefaultCloneDir(t *testing.T) {
|
||||
// --- pure-function tests ---
|
||||
|
||||
func TestParseRepoURL(t *testing.T) {
|
||||
result, err := parseCredentialInitEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git","commit_author_name":"Alice","commit_author_email":"alice@example.com"}}`)
|
||||
url, err := parseRepoURLFromEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git"}}`)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.RepositoryURL != "http://u:t@h/app_x.git" {
|
||||
t.Errorf("RepositoryURL got %q", result.RepositoryURL)
|
||||
}
|
||||
if result.CommitAuthorName != "Alice" {
|
||||
t.Errorf("CommitAuthorName got %q", result.CommitAuthorName)
|
||||
}
|
||||
if result.CommitAuthorEmail != "alice@example.com" {
|
||||
t.Errorf("CommitAuthorEmail got %q", result.CommitAuthorEmail)
|
||||
if url != "http://u:t@h/app_x.git" {
|
||||
t.Errorf("got %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepoURL_Errors(t *testing.T) {
|
||||
for _, in := range []string{`not json`, `{"ok":false,"data":{}}`, `{"ok":true,"data":{}}`, `{"ok":true,"data":{"repository_url":""}}`} {
|
||||
if _, err := parseCredentialInitEnvelope(in); err == nil {
|
||||
if _, err := parseRepoURLFromEnvelope(in); err == nil {
|
||||
t.Errorf("expected error for %q", in)
|
||||
}
|
||||
}
|
||||
@@ -156,22 +149,6 @@ func withFakeRunner(t *testing.T, f *fakeCommandRunner) {
|
||||
t.Cleanup(func() { initRunner = orig })
|
||||
}
|
||||
|
||||
func stubAppType(reg *httpmock.Registry, appID, appType string) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/" + appID,
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"app_id": appID,
|
||||
"app_type": appType,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func credInitOK(repoURL string) fakeCallResult {
|
||||
return fakeCallResult{stdout: `{"ok":true,"data":{"repository_url":"` + repoURL + `"}}`}
|
||||
}
|
||||
@@ -336,8 +313,7 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
|
||||
"git status": {stdout: " M src/app.ts\n"}, // scaffold produced changes
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -378,8 +354,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
}
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
@@ -448,8 +423,7 @@ func TestAppsInit_HappyPathCleanTree(t *testing.T) {
|
||||
"git status": {}, // clean tree after scaffold -> no commit/push
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
@@ -498,8 +472,7 @@ func TestAppsInit_DirtyTreeCommitPush(t *testing.T) {
|
||||
"git status": {stdout: " M file.txt"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
@@ -569,8 +542,7 @@ func TestAppsInit_CloneFailure(t *testing.T) {
|
||||
"git clone": {stderr: "fatal: unable to access 'http://u:t@h/r.git'", err: errors.New("exit 128")},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
@@ -644,8 +616,7 @@ func TestAppsInit_AsPassthrough(t *testing.T) {
|
||||
"git status": {},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
|
||||
// AppsInit.AuthTypes is ["user"], so the framework rejects --as bot. Use
|
||||
@@ -751,7 +722,7 @@ func TestIsEmptyRepo(t *testing.T) {
|
||||
// newAppsExecuteFactoryWithStderr mirrors newAppsExecuteFactory but also returns
|
||||
// the stderr buffer, so tests can assert on the +init progress log lines that
|
||||
// initLogf writes to IO().ErrOut.
|
||||
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
@@ -761,12 +732,12 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, stdout, stderr, reg := cmdutil.TestFactory(t, cfg)
|
||||
return factory, stdout, stderr, reg
|
||||
factory, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
return factory, stdout, stderr
|
||||
}
|
||||
|
||||
func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
factory, stdout, _, _ := newAppsExecuteFactoryWithStderr(t)
|
||||
factory, stdout, _ := newAppsExecuteFactoryWithStderr(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -795,8 +766,7 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
|
||||
"git status": {},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory2, stdout2, stderr2, reg2 := newAppsExecuteFactoryWithStderr(t)
|
||||
stubAppType(reg2, "app_x", "FULL_STACK")
|
||||
factory2, stdout2, stderr2 := newAppsExecuteFactoryWithStderr(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("run err=%v", err)
|
||||
@@ -859,8 +829,7 @@ func TestAppsInit_EmptyRepo_TwoCommits(t *testing.T) {
|
||||
"git status": {stdout: " A src/app.ts\n A .spark/meta.json\n A .agent/skills/steering/x.md\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -901,8 +870,7 @@ func TestAppsInit_EmptyRepo_AppCodeOnly_SingleCommit(t *testing.T) {
|
||||
"git status": {stdout: " A src/app.ts\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -922,8 +890,7 @@ func TestAppsInit_EmptyRepo_ConfigOnly_SingleCommit(t *testing.T) {
|
||||
"git status": {stdout: " A .spark/meta.json\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -943,8 +910,7 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
|
||||
"git status": {stdout: " M file.txt\n M .spark/meta.json\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
@@ -963,7 +929,8 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
|
||||
// gitMust runs a git command in dir with a real binary, failing the test on error.
|
||||
func gitMust(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := gitcmd.Command(dir, args...)
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v in %s failed: %v\n%s", args, dir, err, out)
|
||||
@@ -979,7 +946,6 @@ func TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
gitcmd.SetSynchronousMaintenanceEnv(t)
|
||||
// Bare remote so `git push origin sprint/default` succeeds.
|
||||
remote := t.TempDir()
|
||||
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
|
||||
@@ -1101,7 +1067,6 @@ func TestCommitAndPushIfDirty_RealGit_NonEmptyUpgrade(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not available")
|
||||
}
|
||||
gitcmd.SetSynchronousMaintenanceEnv(t)
|
||||
remote := t.TempDir()
|
||||
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
|
||||
|
||||
@@ -1324,8 +1289,7 @@ func TestAppsInit_EnvPull_Success(t *testing.T) {
|
||||
"env-pull": envPullOK("/abs/app_x/.env.local"),
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -1363,8 +1327,7 @@ func TestAppsInit_EnvPull_NonFatal(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
dir := relCloneDir(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
|
||||
@@ -1403,8 +1366,7 @@ func TestAppsInit_AlreadyInitialized_RunsEnvPull(t *testing.T) {
|
||||
envFile := filepath.Join(abs, ".env.local")
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -1451,8 +1413,7 @@ func TestAppsInit_AlreadyInitialized_EnvPullFailure_NonFatal(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stubAppType(reg, "app_x", "FULL_STACK")
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
|
||||
}
|
||||
@@ -1744,15 +1705,13 @@ func TestScaffoldInitArgs_WithAppType(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPolicyForAppType(t *testing.T) {
|
||||
// modern_html and html decouple all control points: skip install, env-pull, skills sync, app sync.
|
||||
for _, at := range []string{"modern_html", "html"} {
|
||||
if p := policyForAppType(at); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync || !p.skipAppSync {
|
||||
t.Errorf("%s policy = %+v, want all skip flags set", at, p)
|
||||
}
|
||||
// modern_html decouples all control points: skip install, env-pull, skills sync.
|
||||
if p := policyForAppType("modern_html"); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync {
|
||||
t.Errorf("modern_html policy = %+v, want all skip flags set", p)
|
||||
}
|
||||
// Unlisted types (including "") get the zero-value policy: everything runs.
|
||||
for _, at := range []string{"full_stack", "", "backend"} {
|
||||
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync || p.skipAppSync {
|
||||
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync {
|
||||
t.Errorf("policy for %q = %+v, want zero value", at, p)
|
||||
}
|
||||
}
|
||||
@@ -1798,7 +1757,7 @@ func configSetValue(calls [][]string, key string) (string, bool) {
|
||||
func TestEnsureGitIdentity_SetsDefaultsWhenUnset(t *testing.T) {
|
||||
f := &fakeCommandRunner{} // no "git config" result → `--get` returns empty stdout
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v, ok := configSetValue(f.calls, "user.name"); !ok || v != defaultGitUserName {
|
||||
@@ -1815,7 +1774,7 @@ func TestEnsureGitIdentity_RespectsExisting(t *testing.T) {
|
||||
"git config": {stdout: "Existing Dev\n"},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := configSetValue(f.calls, "user.name"); ok {
|
||||
@@ -1831,7 +1790,7 @@ func TestEnsureGitIdentity_SetFailurePropagates(t *testing.T) {
|
||||
"git config": {stderr: "boom", err: errors.New("exit 1")},
|
||||
}}
|
||||
withFakeRunner(t, f)
|
||||
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err == nil {
|
||||
if err := ensureGitIdentity(context.Background(), "/repo"); err == nil {
|
||||
t.Error("expected error when git config set fails")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,25 +13,22 @@ import (
|
||||
)
|
||||
|
||||
// queryAppType fetches the app's type string from the server via
|
||||
// GET /open-apis/spark/v1/apps/{identifier}. The identifier can be either
|
||||
// an app_id or a meta_token — the server resolves both. The server returns
|
||||
// uppercase app_type values ("HTML", "FULL_STACK", "MODERN_HTML");
|
||||
// this function normalizes to lowercase. Returns an error when the API
|
||||
// is unavailable or the response is malformed — callers must not proceed
|
||||
// with a fallback type to avoid creating the wrong project scaffold.
|
||||
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, identifier string) (string, error) {
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(identifier))
|
||||
// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
|
||||
// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
|
||||
// to lowercase. Returns "" when the API is unavailable or returns an
|
||||
// error — callers fall back to legacy behavior.
|
||||
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
|
||||
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("GET", path, nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
appRaw, _ := data["app"].(map[string]interface{})
|
||||
if appRaw == nil {
|
||||
return "", appsSubprocessEnvelopeError("query app type: response missing app object")
|
||||
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: response missing app object\n")
|
||||
return ""
|
||||
}
|
||||
appType, _ := appRaw["app_type"].(string)
|
||||
if strings.TrimSpace(appType) == "" {
|
||||
return "", appsSubprocessEnvelopeError("query app type: response missing app_type")
|
||||
}
|
||||
return strings.ToLower(appType), nil
|
||||
return strings.ToLower(appType)
|
||||
}
|
||||
|
||||
@@ -43,10 +43,7 @@ func TestQueryAppType_Success(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result, err := queryAppType(context.Background(), rt, "app_test")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
result := queryAppType(context.Background(), rt, "app_test")
|
||||
if result != "modern_html" {
|
||||
t.Errorf("queryAppType = %q, want modern_html", result)
|
||||
}
|
||||
@@ -68,10 +65,7 @@ func TestQueryAppType_FullStack(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result, err := queryAppType(context.Background(), rt, "app_fs")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
result := queryAppType(context.Background(), rt, "app_fs")
|
||||
if result != "full_stack" {
|
||||
t.Errorf("queryAppType = %q, want full_stack", result)
|
||||
}
|
||||
@@ -93,10 +87,7 @@ func TestQueryAppType_Html(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
result, err := queryAppType(context.Background(), rt, "app_html")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
result := queryAppType(context.Background(), rt, "app_html")
|
||||
if result != "html" {
|
||||
t.Errorf("queryAppType = %q, want html", result)
|
||||
}
|
||||
@@ -111,9 +102,9 @@ func TestQueryAppType_APIError(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
|
||||
})
|
||||
|
||||
_, err := queryAppType(context.Background(), rt, "app_bad")
|
||||
if err == nil {
|
||||
t.Error("expected error on API failure")
|
||||
result := queryAppType(context.Background(), rt, "app_bad")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty on error", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,9 +119,9 @@ func TestQueryAppType_MissingAppObject(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
_, err := queryAppType(context.Background(), rt, "app_no")
|
||||
if err == nil {
|
||||
t.Error("expected error when app object missing")
|
||||
result := queryAppType(context.Background(), rt, "app_no")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app object missing", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,8 +141,8 @@ func TestQueryAppType_EmptyAppType(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
_, err := queryAppType(context.Background(), rt, "app_empty")
|
||||
if err == nil {
|
||||
t.Error("expected error when app_type is empty")
|
||||
result := queryAppType(context.Background(), rt, "app_empty")
|
||||
if result != "" {
|
||||
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,9 @@ var AppsReleaseCreate = common.Shortcut{
|
||||
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if err := validateRealAppID(appID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
|
||||
@@ -30,13 +30,9 @@ var AppsReleaseGet = common.Shortcut{
|
||||
{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
if appID == "" {
|
||||
if strings.TrimSpace(rctx.Str("app-id")) == "" {
|
||||
return appsValidationParamError("--app-id", "--app-id is required")
|
||||
}
|
||||
if err := validateRealAppID(appID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("release-id")) == "" {
|
||||
return appsValidationParamError("--release-id", "--release-id is required")
|
||||
}
|
||||
|
||||
@@ -41,21 +41,6 @@ func withAppsHint(err error, hint string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// validateRealAppID checks that --app-id is a real app ID (app_ prefix).
|
||||
// meta_token values are rejected with a hint to resolve via +get first.
|
||||
func validateRealAppID(appID string) error {
|
||||
if !strings.HasPrefix(appID, "app_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
`--app-id must be an app_id starting with "app_".`,
|
||||
).WithParam("--app-id").WithHint(
|
||||
`If you have a meta_token or a /page/<token>/ link, first resolve it:
|
||||
lark-cli apps +get --app-id <meta_token> -q '.data.app.app_id'
|
||||
Then retry this command with the returned app_id.`,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
|
||||
// --output path. The authoritative guard is the local FileIO layer
|
||||
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and
|
||||
|
||||
@@ -75,7 +75,6 @@ var AppsGitCredentialInit = common.Shortcut{
|
||||
"save the issued PAT in the local system credential store",
|
||||
"write app-scoped git credential metadata",
|
||||
"configure a URL-scoped Git credential helper in global git config when possible",
|
||||
"return commit_author_name and commit_author_email for repo-local git identity",
|
||||
}).
|
||||
Params(gitCredentialIssueParams(appID))
|
||||
},
|
||||
@@ -91,12 +90,6 @@ var AppsGitCredentialInit = common.Shortcut{
|
||||
"repository_url": result.GitHTTPURL,
|
||||
"status": initStatus(result),
|
||||
}
|
||||
if result.CommitAuthorName != "" {
|
||||
payload["commit_author_name"] = result.CommitAuthorName
|
||||
}
|
||||
if result.CommitAuthorEmail != "" {
|
||||
payload["commit_author_email"] = result.CommitAuthorEmail
|
||||
}
|
||||
if result.ConfigWarning != "" {
|
||||
payload["git_config_warning"] = result.ConfigWarning
|
||||
}
|
||||
@@ -468,13 +461,11 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
|
||||
}
|
||||
}
|
||||
issued := &gitcred.IssuedCredential{
|
||||
AppID: firstString(source, "app_id", appID),
|
||||
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
|
||||
Username: firstString(source, "username"),
|
||||
PAT: firstString(source, "token", "Token", "pat", "password"),
|
||||
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
|
||||
CommitAuthorName: firstString(source, "commit_author_name"),
|
||||
CommitAuthorEmail: firstString(source, "commit_author_email"),
|
||||
AppID: firstString(source, "app_id", appID),
|
||||
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
|
||||
Username: firstString(source, "username"),
|
||||
PAT: firstString(source, "token", "Token", "pat", "password"),
|
||||
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
|
||||
}
|
||||
if issued.AppID == "" {
|
||||
issued.AppID = appID
|
||||
|
||||
@@ -87,7 +87,6 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
|
||||
"save the issued PAT in the local system credential store",
|
||||
"write app-scoped git credential metadata",
|
||||
"configure a URL-scoped Git credential helper in global git config when possible",
|
||||
"return commit_author_name and commit_author_email for repo-local git identity",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -129,13 +129,7 @@ func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string
|
||||
if previous != nil && previous.PATRef != "" && previous.PATRef != ref {
|
||||
_ = m.Secrets.Remove(previous.PATRef)
|
||||
}
|
||||
result := &InitResult{
|
||||
AppID: appID,
|
||||
GitHTTPURL: url,
|
||||
Refreshed: previous != nil,
|
||||
CommitAuthorName: issued.CommitAuthorName,
|
||||
CommitAuthorEmail: issued.CommitAuthorEmail,
|
||||
}
|
||||
result := &InitResult{AppID: appID, GitHTTPURL: url, Refreshed: previous != nil}
|
||||
if m.GitConfig != nil {
|
||||
if err := m.GitConfig.SetHelper(ctx, url, appID); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
|
||||
@@ -51,22 +51,18 @@ type CredentialRecord struct {
|
||||
}
|
||||
|
||||
type IssuedCredential struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Username string
|
||||
PAT string
|
||||
ExpiresAt int64
|
||||
CommitAuthorName string
|
||||
CommitAuthorEmail string
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Username string
|
||||
PAT string
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
type InitResult struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Refreshed bool
|
||||
ConfigWarning string
|
||||
CommitAuthorName string
|
||||
CommitAuthorEmail string
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Refreshed bool
|
||||
ConfigWarning string
|
||||
}
|
||||
|
||||
type RemoveResult struct {
|
||||
|
||||
73
shortcuts/apps/html_publish_client.go
Normal file
73
shortcuts/apps/html_publish_client.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type htmlPublishResponse struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
type appsHTMLPublishClient interface {
|
||||
HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error)
|
||||
}
|
||||
|
||||
type appsHTMLPublishAPI struct {
|
||||
runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddFile("file", bytes.NewReader(tarball.Body))
|
||||
|
||||
apiResp, err := api.runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID)),
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
}
|
||||
data, err := api.runtime.ClassifyAPIResponse(apiResp)
|
||||
if err != nil {
|
||||
return nil, enrichHTMLPublishAPIError(err)
|
||||
}
|
||||
url, _ := data["url"].(string)
|
||||
if url == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"html-publish response is missing the published app url")
|
||||
}
|
||||
return &htmlPublishResponse{URL: url}, nil
|
||||
}
|
||||
|
||||
// OAPI business error codes returned by the
|
||||
// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend
|
||||
// service; update when new codes are documented in the OAPI spec.
|
||||
const (
|
||||
errCodeBuildFailed = 90001 // tar.gz uploaded but server-side build failed
|
||||
errCodeAppNotFound = 90002 // app_id unknown or caller lacks permission
|
||||
)
|
||||
|
||||
func buildHTMLPublishFailureHint(code int) string {
|
||||
switch code {
|
||||
case errCodeBuildFailed:
|
||||
return "server-side build failed: run `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` to inspect the packaged file list"
|
||||
case errCodeAppNotFound:
|
||||
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
197
shortcuts/apps/html_publish_client_test.go
Normal file
197
shortcuts/apps/html_publish_client_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test",
|
||||
}
|
||||
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
rctx := common.TestNewRuntimeContextForAPI(context.Background(), nil, cfg, factory, core.AsUser)
|
||||
return rctx, reg
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_Success(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"url": "https://miaoda.feishu.cn/app/app_x",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
tarball := &htmlPublishTarball{Body: []byte("fake"), Size: 4, SHA256: "abc"}
|
||||
resp, err := api.HTMLPublish(context.Background(), "app_x", tarball)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if resp.URL != "https://miaoda.feishu.cn/app/app_x" {
|
||||
t.Fatalf("url=%q", resp.URL)
|
||||
}
|
||||
|
||||
ct := stub.CapturedHeaders.Get("Content-Type")
|
||||
mt, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil || mt != "multipart/form-data" {
|
||||
t.Fatalf("content type %q wrong", ct)
|
||||
}
|
||||
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
saw := false
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if p.FormName() == "file" {
|
||||
saw = true
|
||||
}
|
||||
}
|
||||
if !saw {
|
||||
t.Fatalf("multipart missing 'file' part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 90001,
|
||||
"msg": "build failed: dependency conflict",
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
problem := requireAppsAPIProblem(t, err)
|
||||
if problem.Code != errCodeBuildFailed {
|
||||
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint on code 90001")
|
||||
}
|
||||
if !strings.Contains(problem.Message, "build failed") {
|
||||
t.Fatalf("missing failure message: %v", problem.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_AppNotFoundClassified(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_missing/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": errCodeAppNotFound,
|
||||
"msg": "app not found",
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_missing", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsAPIProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeNotFound {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected app-not-found recovery hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_MissingURLIsInvalidResponse(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishFailureHint_UnknownCodeReturnsEmpty(t *testing.T) {
|
||||
// 默认分支:未识别的 code 返回空 hint,让 Agent 用 message 兜底。
|
||||
if hint := buildHTMLPublishFailureHint(99999); hint != "" {
|
||||
t.Fatalf("unknown code should return empty hint, got %q", hint)
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(0); hint != "" {
|
||||
t.Fatalf("zero code should return empty hint, got %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishFailureHint_KnownCodes(t *testing.T) {
|
||||
if hint := buildHTMLPublishFailureHint(90001); hint == "" {
|
||||
t.Fatalf("code 90001 should return non-empty hint")
|
||||
}
|
||||
if hint := buildHTMLPublishFailureHint(90002); hint == "" {
|
||||
t.Fatalf("code 90002 should return non-empty hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing.T) {
|
||||
hint := buildHTMLPublishFailureHint(90002)
|
||||
if hint == "" {
|
||||
t.Fatalf("code 90002 should return non-empty hint")
|
||||
}
|
||||
if strings.Contains(hint, "+list") {
|
||||
t.Fatalf("hint must not point at hidden +list command, got: %q", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "app_id") {
|
||||
t.Fatalf("hint should reference app_id, got: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) {
|
||||
rctx, reg := newAppsClientRuntime(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
|
||||
RawBody: []byte("{not json"),
|
||||
})
|
||||
|
||||
api := appsHTMLPublishAPI{runtime: rctx}
|
||||
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
|
||||
problem := requireAppsProblem(t, err, errs.CategoryInternal)
|
||||
if problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
@@ -104,22 +104,6 @@ func TestDryRunFieldOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open")
|
||||
|
||||
autoNumberRT := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"field-id": "fld_1",
|
||||
"json": `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
autoNumberDR := dryRunFieldUpdate(ctx, autoNumberRT)
|
||||
assertDryRunContains(t, autoNumberDR, "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1", `"name":"编号"`, `"type":"auto_number"`, `"rules":[`, `"length":4`)
|
||||
if out := autoNumberDR.Format(); strings.Contains(out, "auto_serial") || strings.Contains(out, "reformat_existing_records") || strings.Contains(out, "/open-apis/bitable/v1/") {
|
||||
t.Fatalf("auto_number dry-run must stay on v3 field JSON, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunRecordOps(t *testing.T) {
|
||||
@@ -133,7 +117,7 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
|
||||
|
||||
listFieldNamesAliasRT := newBaseTestRuntimeWithArrays(
|
||||
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"field-names": {"Name", "Age"}},
|
||||
nil,
|
||||
|
||||
@@ -81,37 +81,6 @@ func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func assertInvalidArgumentValidation(t *testing.T, err error, wantParam string, wantParams []string, messageContains string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid-argument validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected invalid-argument validation problem, got %T %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != wantParam {
|
||||
t.Fatalf("param=%q, want %q", validationErr.Param, wantParam)
|
||||
}
|
||||
if wantParams != nil {
|
||||
if len(validationErr.Params) != len(wantParams) {
|
||||
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
|
||||
}
|
||||
for i, want := range wantParams {
|
||||
if validationErr.Params[i].Name != want {
|
||||
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
|
||||
}
|
||||
}
|
||||
}
|
||||
if messageContains != "" && !strings.Contains(err.Error(), messageContains) {
|
||||
t.Fatalf("err=%v, want message containing %q", err, messageContains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
@@ -153,7 +122,7 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
if grant["user_open_id"] != "ou_testuser" {
|
||||
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
||||
}
|
||||
if grant["message"] != "Granted the current CLI user full_access on the new base." {
|
||||
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
|
||||
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
||||
}
|
||||
|
||||
@@ -500,6 +469,9 @@ func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testi
|
||||
if grant["status"] != common.PermissionGrantFailed {
|
||||
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
|
||||
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
|
||||
}
|
||||
if !strings.Contains(grant["message"].(string), "retry later") {
|
||||
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
||||
}
|
||||
@@ -605,9 +577,8 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
wantDesc := "After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
|
||||
if got := stdout.String(); !strings.Contains(got, wantDesc) {
|
||||
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -616,9 +587,8 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
wantDesc := "After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
|
||||
if got := stdout.String(); !strings.Contains(got, wantDesc) {
|
||||
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
|
||||
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -627,7 +597,7 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
||||
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access") {
|
||||
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -849,189 +819,8 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldUpdateResultAlwaysRecommendsReadback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
field interface{}
|
||||
submitted map[string]interface{}
|
||||
hintContains []string
|
||||
}{
|
||||
{
|
||||
name: "direct complex server type overrides simple submitted type",
|
||||
field: map[string]interface{}{"type": "auto_number"},
|
||||
submitted: map[string]interface{}{"type": "number"},
|
||||
hintContains: []string{`submitted type "number"`, `server returned type "auto_number"`},
|
||||
},
|
||||
{
|
||||
name: "nested simple server type still recommends readback",
|
||||
field: map[string]interface{}{"field": map[string]interface{}{"type": "number"}},
|
||||
submitted: map[string]interface{}{"type": "auto_number"},
|
||||
hintContains: []string{`submitted type "auto_number"`, `server returned type "number"`},
|
||||
},
|
||||
{
|
||||
name: "submitted simple type still recommends readback when response omits type",
|
||||
field: map[string]interface{}{"id": "fld_x"},
|
||||
submitted: map[string]interface{}{"type": "text"},
|
||||
hintContains: []string{`type "text"`, "cannot determine the previous type"},
|
||||
},
|
||||
{
|
||||
name: "missing type is conservative",
|
||||
field: map[string]interface{}{"id": "fld_x"},
|
||||
submitted: map[string]interface{}{"name": "Amount"},
|
||||
hintContains: []string{"unknown or uncommon field type", "+field-get"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := fieldUpdateResult(map[string]interface{}{"field": tc.field, "updated": true}, tc.submitted)
|
||||
if got["field_get_recommended"] != true || got["next_step"] != "field_get" {
|
||||
t.Fatalf("result=%#v, want readback recommendation", got)
|
||||
}
|
||||
hint, _ := got["verification_hint"].(string)
|
||||
for _, want := range tc.hintContains {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("verification_hint=%q, want substring %q", hint, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldExecuteUpdateNoopReturnsAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 800070003,
|
||||
"msg": "no operation produced",
|
||||
},
|
||||
})
|
||||
err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected the API no-op response to surface as an error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed API error, got %T %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeUnknown || p.Code != 800070003 {
|
||||
t.Fatalf("category/subtype/code=%s/%s/%d", p.Category, p.Subtype, p.Code)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T %v", err, err)
|
||||
}
|
||||
if got := stdout.String(); strings.TrimSpace(got) != "" {
|
||||
t.Fatalf("no success envelope should be emitted on a no-op API error:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldExecuteUpdateAutoNumberUsesV3FieldJSON(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"field": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`
|
||||
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
gotBody := string(stub.CapturedBody)
|
||||
for _, want := range []string{
|
||||
`"name":"编号"`,
|
||||
`"type":"auto_number"`,
|
||||
`"rules":[`,
|
||||
`"date_format":"yyyyMM"`,
|
||||
`"length":4`,
|
||||
} {
|
||||
if !strings.Contains(gotBody, want) {
|
||||
t.Fatalf("request body missing %q:\n%s", want, gotBody)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"auto_serial", "reformat_existing_records", `"type":1005`} {
|
||||
if strings.Contains(gotBody, forbidden) {
|
||||
t.Fatalf("request body must not contain v1 field %q:\n%s", forbidden, gotBody)
|
||||
}
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{`"reformat_existing_records"`} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Fatalf("stdout must not expose %q:\n%s", forbidden, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldExecuteUpdateDoesNotRejectExtraJSONKeys(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
// Unknown v3 keys are forwarded unchanged; the server remains the source of
|
||||
// truth for whether a field-update property is supported.
|
||||
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"incremental_number","length":4}]},"reformat_existing_records":true}`
|
||||
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if gotBody := string(stub.CapturedBody); !strings.Contains(gotBody, `"reformat_existing_records":true`) {
|
||||
t.Fatalf("request body must preserve unknown v3 key:\n%s", gotBody)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
|
||||
t.Fatalf("expected successful update, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldValidateAllowsRatingMaxAboveLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
runtime *common.RuntimeContext
|
||||
}{
|
||||
{
|
||||
name: "create",
|
||||
shortcut: BaseFieldCreate,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
shortcut: BaseFieldUpdate,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "field-id": "fld_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := tc.shortcut.Validate(ctx, tc.runtime); err != nil {
|
||||
t.Fatalf("rating max above 10 should not be blocked by CLI validation: %v", err)
|
||||
}
|
||||
})
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1303,32 +1092,8 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Status","type":"text"}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"created": true`, `"fld_new"`, `"field_get_recommended": false`, `"next_step": "done"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create generated field recommends readback", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_auto", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"编号","type":"auto_number"}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"created": true`, `"fld_auto"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1375,58 +1140,11 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("fields len=%d output=%#v", len(fields), data)
|
||||
}
|
||||
if data["field_get_recommended"] != false || data["next_step"] != "done" || data["verification_hint"] == nil {
|
||||
t.Fatalf("simple batch create must carry field_get_recommended:false + next_step:done + verification_hint: %#v", data)
|
||||
}
|
||||
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
|
||||
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create array with generated field recommends readback", func(t *testing.T) {
|
||||
oldDelay := fieldCreateBatchDelay
|
||||
fieldCreateBatchDelay = 0
|
||||
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
|
||||
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"name":"Title"`)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"name":"编号"`)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_no", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"Title","type":"text"},{"name":"编号","type":"auto_number"}]`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["created"] != true || data["total"] != float64(2) {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if _, ok := data["fields"].([]interface{}); !ok {
|
||||
t.Fatalf("batch create must keep fields array: %#v", data)
|
||||
}
|
||||
if data["field_get_recommended"] != true || data["next_step"] != "field_get" || data["verification_hint"] == nil {
|
||||
t.Fatalf("batch with auto_number must recommend readback: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1601,32 +1319,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list field names alias preserves quoted commas and at-sign names", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=A%2CB&field_id=%40Owner&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"A,B", "@Owner"},
|
||||
"record_id_list": []interface{}{"rec_alias_special"},
|
||||
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{
|
||||
"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1",
|
||||
"--field-names", `"A,B",@Owner`, "--format", "json",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_alias_special"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1923,162 +1615,28 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list fields alias accepts JSON array projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_fields"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--fields", `["Name","Age"]`, "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list field names alias accepts repeated projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_fields"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name", "--field-names", "Age", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list projection aliases report only supplied ambiguous inputs", func(t *testing.T) {
|
||||
baseArgs := []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantParam string
|
||||
wantParams []string
|
||||
}{
|
||||
{name: "canonical and fields alias", args: []string{"--field-id", "Name", "--fields", `["Age"]`}, wantParam: "--field-id", wantParams: []string{"--field-id", "--fields"}},
|
||||
{name: "canonical and field names alias", args: []string{"--field-id", "Name", "--field-names", "Age"}, wantParam: "--field-id", wantParams: []string{"--field-id", "--field-names"}},
|
||||
{name: "compatibility aliases", args: []string{"--fields", `["Name"]`, "--field-names", "Age"}, wantParam: "--fields", wantParams: []string{"--fields", "--field-names"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := append(append([]string{}, baseArgs...), tc.args...)
|
||||
err := runShortcut(t, BaseRecordList, args, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, tc.wantParam, tc.wantParams, "mutually exclusive")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Hint != "Use only --field-id for projection." {
|
||||
t.Fatalf("hint=%q, want canonical projection guidance", validationErr.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search json conflict reports each supplied projection parameter", func(t *testing.T) {
|
||||
t.Run("list legacy fields flag rejected", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordSearch, []string{
|
||||
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
|
||||
"--field-names", "Age",
|
||||
}, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-names"}, "mutually exclusive")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || !strings.Contains(validationErr.Hint, "inside --json") {
|
||||
t.Fatalf("hint=%q, want JSON-body guidance", validationErr.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list canonical and alias projections reject duplicates consistently", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
param string
|
||||
}{
|
||||
{name: "canonical", args: []string{"--field-id", "Cost--USD", "--field-id", "Cost--USD"}, param: "--field-id"},
|
||||
{name: "fields alias", args: []string{"--fields", `["Cost--USD","Cost--USD"]`}, param: "--fields"},
|
||||
{name: "field names alias", args: []string{"--field-names", "Cost--USD", "--field-names", "Cost--USD"}, param: "--field-names"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := append([]string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}, tc.args...)
|
||||
err := runShortcut(t, BaseRecordList, args, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, tc.param, []string{tc.param}, "duplicate field id")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search fields alias accepts JSON array projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_search"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(t, BaseRecordSearch, []string{
|
||||
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--keyword", "Alice", "--search-field", "Name", "--fields", `["Name","Age"]`, "--format", "json",
|
||||
}, factory, stdout); err != nil {
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body := string(searchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
})
|
||||
|
||||
t.Run("list field ids and field names alias are mutually exclusive", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "Name", "--field-names", "Age"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--field-id and --field-names are mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get field names alias accepts repeated projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{
|
||||
"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1",
|
||||
"--field-names", "Name", "--field-names", "Age", "--format", "json",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body := string(batchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
@@ -2435,14 +1993,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name"},
|
||||
"record_id_list": []interface{}{"rec_1", "rec_2"},
|
||||
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"create_records":[{"Name":"Alice"},{"Name":"Bob"}]}`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -2455,14 +2015,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"ignored_fields": []interface{}{"Formula"},
|
||||
"has_more": false,
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"update": map[string]interface{}{"Status": "Done"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"update_records":{"rec_1":{"Status":["Done"]}}}`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Status":"Done"}}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"ignored_fields"`) || !strings.Contains(got, `"Formula"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"update"`) || !strings.Contains(got, `"Done"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -2474,16 +2036,20 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
input := `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`
|
||||
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Name":"Alice","Status":"Done"}}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(body, `"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}`) {
|
||||
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"patch":{"Name":"Alice","Status":"Done"}`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
Body(buildBaseCopyBody(runtime)).
|
||||
Set("base_token", runtime.Str("base-token"))
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
|
||||
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
if runtime.IsBot() {
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
|
||||
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
|
||||
}
|
||||
d.
|
||||
POST("/open-apis/base/v3/bases").
|
||||
|
||||
@@ -28,16 +28,23 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, stringArrayFlags, nil, boolFlags, intFlags)
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithSlices(stringFlags map[string]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, nil, stringSliceFlags, boolFlags, intFlags)
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, stringArrayFlags map[string][]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range stringFlags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range stringArrayFlags {
|
||||
if name == "field-names" {
|
||||
cmd.Flags().StringSlice(name, nil, "")
|
||||
} else {
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
for name := range stringSliceFlags {
|
||||
cmd.Flags().StringSlice(name, nil, "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
@@ -54,6 +61,11 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
}
|
||||
for name, values := range stringSliceFlags {
|
||||
for _, value := range values {
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if value {
|
||||
_ = cmd.Flags().Set(name, "true")
|
||||
@@ -465,40 +477,6 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordProjectionAliasesAreHidden(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
}{
|
||||
{name: "record list", shortcut: BaseRecordList},
|
||||
{name: "record search", shortcut: BaseRecordSearch},
|
||||
{name: "record get", shortcut: BaseRecordGet},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
primary := cmd.Flags().Lookup("field-id")
|
||||
if primary == nil || primary.Hidden {
|
||||
t.Fatalf("public projection flag --field-id missing or hidden: %#v", primary)
|
||||
}
|
||||
help := cmd.Flags().FlagUsages()
|
||||
for _, aliasName := range []string{"fields", "field-names"} {
|
||||
alias := cmd.Flags().Lookup(aliasName)
|
||||
if alias == nil || !alias.Hidden {
|
||||
t.Fatalf("projection alias --%s should exist and be hidden: %#v", aliasName, alias)
|
||||
}
|
||||
if strings.Contains(help, "--"+aliasName) {
|
||||
t.Fatalf("help should not include hidden --%s:\n%s", aliasName, help)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -801,16 +779,14 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
name: "record batch create json",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
wantHelp: []string{
|
||||
"create_records contains one field map per record",
|
||||
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
|
||||
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record batch update json",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
wantHelp: []string{
|
||||
"update_records maps each record ID to its field map",
|
||||
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
|
||||
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -846,13 +822,9 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
"does not auto-upsert by business key",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"do not write system fields, formula, lookup, or attachment fields",
|
||||
"Sub-record/child-record path",
|
||||
"set that link field to a parent record reference array",
|
||||
`{"Parent Link":[{"id":"rec_xxx"}]}`,
|
||||
"do not look for parent_record_id or a separate child-record API",
|
||||
"CellValue happy path: text/phone/url",
|
||||
"select (multiple=false) -> \"Todo\"",
|
||||
"select (multiple=true) -> [\"Tag A\",\"Tag B\"]",
|
||||
"select -> \"Todo\"",
|
||||
"multi-select -> [\"Tag A\",\"Tag B\"]",
|
||||
"datetime -> \"2026-03-24 10:00:00\"",
|
||||
"checkbox -> true/false",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
@@ -866,11 +838,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
name: "record batch create",
|
||||
shortcut: BaseRecordBatchCreate,
|
||||
wantTips: []string{
|
||||
"Happy path field: create_records",
|
||||
"create_records is an array of independent record field maps",
|
||||
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
|
||||
"Happy path fields: fields is the column order",
|
||||
"rows is an array of row arrays",
|
||||
"may use null for empty cells",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"Batch create supports max 200 records per call",
|
||||
"Batch create supports max 200 rows per call",
|
||||
"do not immediately +record-list the same table",
|
||||
"CellValue happy path: text/phone/url",
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
|
||||
@@ -882,11 +854,9 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
name: "record batch update",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
wantTips: []string{
|
||||
"Happy path field: update_records",
|
||||
"update_records maps each record ID to its own field map",
|
||||
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
|
||||
"contains only optional ignored_fields",
|
||||
"does not check whether record IDs exist",
|
||||
"Happy path fields: record_id_list is the target record IDs",
|
||||
"patch is a field map applied unchanged to every target record",
|
||||
"Do not use +record-batch-update for per-row different values",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"Batch update supports max 200 records per call",
|
||||
"CellValue happy path: text/phone/url",
|
||||
@@ -1000,17 +970,11 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
if strings.Contains(help, "reformat-existing-records") {
|
||||
t.Fatalf("+field-update must not expose a --reformat-existing-records flag:\n%s", help)
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
||||
`Example auto_number update: lark-cli base +field-update`,
|
||||
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers`,
|
||||
"just submit the target field definition and do not add extra low-level parameters",
|
||||
"full field-definition PUT semantics",
|
||||
"Read the current field first with +field-get",
|
||||
"Type conversion is allowlist-based",
|
||||
@@ -1023,9 +987,6 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
if strings.Contains(tips, "--reformat-existing-records") {
|
||||
t.Fatalf("+field-update tips must not ask agents to pass --reformat-existing-records:\n%s", tips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
@@ -1148,10 +1109,6 @@ func TestBaseFieldValidate(t *testing.T) {
|
||||
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil {
|
||||
t.Fatalf("formula update validate err=%v", err)
|
||||
}
|
||||
autoNumberJSON := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"incremental_number","length":4}]}}`
|
||||
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": autoNumberJSON}, nil, nil)); err != nil {
|
||||
t.Fatalf("auto number update validate err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTableValidate(t *testing.T) {
|
||||
@@ -1273,89 +1230,13 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
)); err != nil {
|
||||
t.Fatalf("record search json with sort-json validate err=%v", err)
|
||||
}
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--keyword"}, "mutually exclusive")
|
||||
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "fields": "Name"},
|
||||
map[string][]string{"field-id": {"fld_name"}},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-id", "--fields"}, "mutually exclusive")
|
||||
}
|
||||
|
||||
func TestBaseRecordSearchProjectionLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fields := make([]string, 51)
|
||||
for i := range fields {
|
||||
fields[i] = "Field " + strconv.Itoa(i+1)
|
||||
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}, "field-id": fields[:50]},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("50 projection fields should be accepted: %v", err)
|
||||
}
|
||||
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}, "field-id": fields},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--field-id", []string{"--field-id"}, "maximum limit of 50")
|
||||
|
||||
body, marshalErr := json.Marshal(map[string]interface{}{
|
||||
"keyword": "Alice",
|
||||
"search_fields": []string{"Name"},
|
||||
"select_fields": fields,
|
||||
})
|
||||
if marshalErr != nil {
|
||||
t.Fatalf("marshal search body: %v", marshalErr)
|
||||
}
|
||||
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": string(body)},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maximum limit of 50")
|
||||
}
|
||||
|
||||
func TestRecordSearchJSONNullProjectionIsOmitted(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(map[string]string{
|
||||
"json": `{"keyword":"Alice","search_fields":["Name"],"select_fields":null,"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
|
||||
}, nil, nil)
|
||||
body, err := recordSearchJSONBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("recordSearchJSONBody() error = %v", err)
|
||||
}
|
||||
if _, exists := body["select_fields"]; exists {
|
||||
t.Fatalf("select_fields:null must normalize to omitted, body=%#v", body)
|
||||
}
|
||||
if sortConfig, ok := body["sort"].([]interface{}); !ok || len(sortConfig) != 1 {
|
||||
t.Fatalf("sort normalization must continue after omitting null select_fields, body=%#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordSearchJSONProjectionParamIgnoresFlagLikeFieldNames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "b",
|
||||
"table-id": "tbl_1",
|
||||
"json": `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "duplicate field id")
|
||||
}
|
||||
|
||||
func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,6 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,10 +36,7 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
|
||||
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
bodies, err := parseFieldCreateBodies(pc, runtime.Str("json"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
|
||||
dr := common.NewDryRunAPI().
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
@@ -52,10 +48,7 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo
|
||||
|
||||
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
||||
Body(body).
|
||||
@@ -173,10 +166,10 @@ func executeFieldCreate(runtime *common.RuntimeContext) error {
|
||||
fields = append(fields, data)
|
||||
}
|
||||
if len(fields) == 1 {
|
||||
runtime.Out(fieldCreateResult(map[string]interface{}{"field": fields[0], "created": true}, bodies[0]), nil)
|
||||
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
|
||||
return nil
|
||||
}
|
||||
runtime.Out(fieldCreateBatchResult(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, bodies), nil)
|
||||
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -204,101 +197,10 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(fieldUpdateResult(map[string]interface{}{"field": data, "updated": true}, body), nil)
|
||||
runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fieldCreateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
|
||||
readbackRecommended, reason := fieldWriteReadbackRecommendation(submitted, "create")
|
||||
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
|
||||
}
|
||||
|
||||
// fieldCreateBatchResult attaches the same top-level readback contract to a
|
||||
// multi-field create. It recommends +field-get when any submitted field is a
|
||||
// computed/linked/generated (or unknown) type, so agents know when to verify
|
||||
// server state without breaking the existing fields/total structure.
|
||||
func fieldCreateBatchResult(result map[string]interface{}, submitted []map[string]interface{}) map[string]interface{} {
|
||||
recommend := false
|
||||
reason := "simple fields created successfully; use +field-get only when extra properties or explicit verification are needed"
|
||||
for _, body := range submitted {
|
||||
if rec, r := fieldWriteReadbackRecommendation(body, "create"); rec {
|
||||
recommend = true
|
||||
reason = r
|
||||
break
|
||||
}
|
||||
}
|
||||
return attachFieldReadbackRecommendation(result, recommend, reason)
|
||||
}
|
||||
|
||||
func fieldUpdateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
|
||||
returnedType := normalizeFieldType(fieldResultType(result["field"]))
|
||||
submittedType := normalizeFieldType(common.GetString(submitted, "type"))
|
||||
readbackRecommended, reason := fieldUpdateReadbackRecommendation(returnedType, submittedType)
|
||||
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
|
||||
}
|
||||
|
||||
func fieldUpdateReadbackRecommendation(returnedType, submittedType string) (bool, string) {
|
||||
if returnedType != "" && submittedType != "" && returnedType != submittedType {
|
||||
return true, fmt.Sprintf("field update submitted type %q but the server returned type %q; run +field-get and verify record values before declaring completion", submittedType, returnedType)
|
||||
}
|
||||
|
||||
fieldType := returnedType
|
||||
if fieldType == "" {
|
||||
fieldType = submittedType
|
||||
}
|
||||
if recommended, reason := fieldTypeReadbackRecommendation(fieldType, "update"); recommended {
|
||||
return true, reason + "; sample record values when generated, computed, or converted values are in scope"
|
||||
}
|
||||
return true, fmt.Sprintf("field update request succeeded for type %q, but +field-update cannot determine the previous type; run +field-get and sample record values if the type changed before declaring completion", fieldType)
|
||||
}
|
||||
|
||||
func attachFieldReadbackRecommendation(result map[string]interface{}, readbackRecommended bool, reason string) map[string]interface{} {
|
||||
result["field_get_recommended"] = readbackRecommended
|
||||
result["verification_hint"] = reason
|
||||
if readbackRecommended {
|
||||
result["next_step"] = "field_get"
|
||||
} else {
|
||||
result["next_step"] = "done"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fieldWriteReadbackRecommendation(submitted map[string]interface{}, operation string) (bool, string) {
|
||||
fieldType := normalizeFieldType(common.GetString(submitted, "type"))
|
||||
return fieldTypeReadbackRecommendation(fieldType, operation)
|
||||
}
|
||||
|
||||
func fieldTypeReadbackRecommendation(fieldType, operation string) (bool, string) {
|
||||
fieldType = normalizeFieldType(fieldType)
|
||||
switch fieldType {
|
||||
case "formula", "lookup", "auto_number", "link":
|
||||
return true, fmt.Sprintf("computed, linked, or generated field %s should be verified with +field-get before declaring completion", operation)
|
||||
case "text", "number", "select", "datetime", "checkbox", "user", "group_chat", "attachment", "location":
|
||||
return false, fmt.Sprintf("simple field %s returned successfully; use +field-get only when extra properties or explicit verification are needed", operation)
|
||||
default:
|
||||
return true, "unknown or uncommon field type; run +field-get to avoid assuming the submitted JSON fully describes server state"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFieldType(fieldType string) string {
|
||||
return strings.ToLower(strings.TrimSpace(fieldType))
|
||||
}
|
||||
|
||||
func fieldResultType(value interface{}) string {
|
||||
field, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if fieldType := strings.ToLower(strings.TrimSpace(common.GetString(field, "type"))); fieldType != "" {
|
||||
return fieldType
|
||||
}
|
||||
nested, ok := field["field"].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(common.GetString(nested, "type")))
|
||||
}
|
||||
|
||||
func executeFieldDelete(runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
tableIDValue := baseTableID(runtime)
|
||||
|
||||
@@ -27,7 +27,7 @@ var BaseFieldSearchOptions = common.Shortcut{
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
|
||||
"Use only for select fields, whether multiple is false or true.",
|
||||
"Use only for fields with options, such as select or multi-select fields.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateLimitPageSizeAlias(runtime); err != nil {
|
||||
|
||||
@@ -27,9 +27,7 @@ var BaseFieldUpdate = common.Shortcut{
|
||||
baseHighRiskYesTip,
|
||||
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
|
||||
`Example auto_number update: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "编号" --json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' --yes`,
|
||||
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
|
||||
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers; just submit the target field definition and do not add extra low-level parameters.`,
|
||||
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
|
||||
"Formula and lookup updates require reading the corresponding guide first.",
|
||||
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",
|
||||
|
||||
@@ -238,14 +238,14 @@ func TestRecordSelectionHelpers(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
fields, err = resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Name"}})
|
||||
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
|
||||
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields([]string{"Name"}, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,12 @@ var BaseRecordBatchCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: `batch create JSON object; create_records contains one field map per record, e.g. {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`, Required: true},
|
||||
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
|
||||
},
|
||||
Tips: append([]string{
|
||||
"Happy path field: create_records is an array of independent record field maps.",
|
||||
`Example: {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}.`,
|
||||
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Batch create supports max 200 records per call.",
|
||||
"Batch create supports max 200 rows per call.",
|
||||
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
|
||||
"Use the record-batch-create guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
|
||||
@@ -12,19 +12,18 @@ import (
|
||||
var BaseRecordBatchUpdate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-batch-update",
|
||||
Description: "Batch update records with record-specific fields",
|
||||
Description: "Batch update records",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:record:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: `batch update JSON object; update_records maps each record ID to its field map, e.g. {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`, Required: true},
|
||||
{Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true},
|
||||
},
|
||||
Tips: append([]string{
|
||||
"Happy path field: update_records maps each record ID to its own field map.",
|
||||
`Example: {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}.`,
|
||||
"The response contains only optional ignored_fields and does not check whether record IDs exist; read records back when confirmation is required.",
|
||||
"Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.",
|
||||
"Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.",
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
|
||||
@@ -21,9 +21,7 @@ var BaseRecordGet = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
recordProjectionFieldFlag("field ID or name to project; repeat to keep only needed columns"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
|
||||
@@ -20,9 +20,8 @@ var BaseRecordList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
recordListFieldRefFlag(),
|
||||
recordListFieldNamesAliasFlag(),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
@@ -45,6 +44,9 @@ var BaseRecordList = common.Shortcut{
|
||||
"Use --field-id repeatedly to keep output small and aligned with the task.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordListFieldAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -59,9 +61,6 @@ var BaseRecordList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordList,
|
||||
@@ -73,6 +72,22 @@ var BaseRecordList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func recordListFieldRefFlag() common.Flag {
|
||||
flag := fieldRefFlag(false)
|
||||
flag.Type = "string_array"
|
||||
flag.Desc = "field ID or name to include; repeat to project only needed fields"
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordListFieldNamesAliasFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "field-names",
|
||||
Type: "string_slice",
|
||||
Desc: "hidden alias for --field-id; accepts comma-separated field names",
|
||||
Hidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
func recordListViewRefFlag() common.Flag {
|
||||
flag := viewRefFlag(false)
|
||||
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
|
||||
@@ -87,3 +102,10 @@ func recordReadFormatFlag() common.Flag {
|
||||
Desc: "output format: markdown (default) | json",
|
||||
}
|
||||
}
|
||||
|
||||
func validateRecordListFieldAlias(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("field-id") && runtime.Changed("field-names") {
|
||||
return baseFlagErrorf("--field-id and --field-names are mutually exclusive; use --field-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,21 +5,18 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxRecordSelectionCount = 200
|
||||
const maxBatchGetSelectFieldCount = 100
|
||||
const maxRecordSearchSelectFieldCount = 50
|
||||
|
||||
var recordCellValueHappyPathTips = []string{
|
||||
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select (multiple=false) -> "Todo"; select (multiple=true) -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
|
||||
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
|
||||
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
|
||||
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
|
||||
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",
|
||||
@@ -49,6 +46,7 @@ func validateRecordSelection(runtime *common.RuntimeContext) error {
|
||||
|
||||
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
|
||||
recordIDs := runtime.StrArray("record-id")
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if len(recordIDs) > 0 && jsonRaw != "" {
|
||||
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
|
||||
@@ -71,11 +69,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
projectionFields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), body)
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
@@ -89,11 +83,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
projectionFields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), nil)
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
@@ -114,20 +104,20 @@ func normalizeRecordIDs(values interface{}) ([]string, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func resolveRecordGetSelectFields(flagFields []string, projectionParam string, body map[string]interface{}) ([]string, error) {
|
||||
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
|
||||
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
|
||||
if err != nil {
|
||||
return nil, withValidationParam(err, projectionParam)
|
||||
return nil, err
|
||||
}
|
||||
if body == nil {
|
||||
return fromFlags, nil
|
||||
}
|
||||
rawJSONFields, ok := body["select_fields"]
|
||||
if !ok || rawJSONFields == nil {
|
||||
if !ok {
|
||||
return fromFlags, nil
|
||||
}
|
||||
if len(fromFlags) > 0 {
|
||||
return nil, baseFlagErrorf(`%s and --json field "select_fields" are mutually exclusive`, projectionParam)
|
||||
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||
}
|
||||
items, ok := rawJSONFields.([]interface{})
|
||||
if !ok {
|
||||
@@ -138,26 +128,18 @@ func resolveRecordGetSelectFields(flagFields []string, projectionParam string, b
|
||||
}
|
||||
normalized, err := normalizeRecordGetSelectFields(items)
|
||||
if err != nil {
|
||||
return nil, withValidationParam(err, "--json")
|
||||
return nil, err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
|
||||
return normalizeRecordSelectFields(values, maxBatchGetSelectFieldCount)
|
||||
}
|
||||
|
||||
func normalizeRecordSearchSelectFields(values interface{}) ([]string, error) {
|
||||
return normalizeRecordSelectFields(values, maxRecordSearchSelectFieldCount)
|
||||
}
|
||||
|
||||
func normalizeRecordSelectFields(values interface{}, max int) ([]string, error) {
|
||||
return normalizeStringList(values, stringListNormalizeOptions{
|
||||
typeError: "field selection must be a string array",
|
||||
itemName: "field selection item",
|
||||
duplicateName: "field id",
|
||||
limitName: "field selection",
|
||||
max: max,
|
||||
max: maxBatchGetSelectFieldCount,
|
||||
allowNil: true,
|
||||
allowEmpty: true,
|
||||
})
|
||||
@@ -229,11 +211,7 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
params := url.Values{}
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
fields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
for _, field := range fields {
|
||||
for _, field := range recordListFields(runtime) {
|
||||
params.Add("field_id", field)
|
||||
}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
@@ -397,121 +375,11 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func recordProjectionFieldFlag(desc string) common.Flag {
|
||||
flag := fieldRefFlag(false)
|
||||
flag.Type = "string_array"
|
||||
flag.Desc = desc
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordProjectionAliasFlag(name string) common.Flag {
|
||||
flagType := "string_array"
|
||||
if name == "field-names" {
|
||||
// Preserve the original compatibility contract: --field-names uses
|
||||
// pflag's CSV parser, including quoted commas, and treats @ literally.
|
||||
flagType = "string_slice"
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
if runtime.Changed("field-names") {
|
||||
return runtime.StrSlice("field-names")
|
||||
}
|
||||
return common.Flag{
|
||||
Name: name,
|
||||
Type: flagType,
|
||||
Desc: "hidden alias for --field-id projection",
|
||||
Hidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
func recordProjectionParam(runtime *common.RuntimeContext) string {
|
||||
switch {
|
||||
case runtime.Changed("fields"):
|
||||
return "--fields"
|
||||
case runtime.Changed("field-names"):
|
||||
return "--field-names"
|
||||
default:
|
||||
return "--field-id"
|
||||
}
|
||||
}
|
||||
|
||||
func withValidationParam(err error, param string) error {
|
||||
if err == nil || param == "" {
|
||||
return err
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
return err
|
||||
}
|
||||
reason := validationErr.Error()
|
||||
// The caller knows which input produced this validation error. Replace any
|
||||
// params inferred from the rendered message: field values such as Cost--USD
|
||||
// must not be mistaken for a --USD flag.
|
||||
validationErr.Param = param
|
||||
validationErr.Params = []errs.InvalidParam{{Name: param, Reason: reason}}
|
||||
return err
|
||||
}
|
||||
|
||||
func recordProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
|
||||
return recordProjectionFieldsWithLimit(runtime, maxBatchGetSelectFieldCount)
|
||||
}
|
||||
|
||||
func recordSearchProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
|
||||
return recordProjectionFieldsWithLimit(runtime, maxRecordSearchSelectFieldCount)
|
||||
}
|
||||
|
||||
func recordProjectionFieldsWithLimit(runtime *common.RuntimeContext, max int) ([]string, error) {
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
fieldIDsSet := runtime.Changed("field-id")
|
||||
fieldsSet := runtime.Changed("fields")
|
||||
fieldNamesSet := runtime.Changed("field-names")
|
||||
projectionParams := make([]string, 0, 3)
|
||||
if fieldIDsSet {
|
||||
projectionParams = append(projectionParams, "--field-id")
|
||||
}
|
||||
if fieldsSet {
|
||||
projectionParams = append(projectionParams, "--fields")
|
||||
}
|
||||
if fieldNamesSet {
|
||||
projectionParams = append(projectionParams, "--field-names")
|
||||
}
|
||||
if len(projectionParams) > 1 {
|
||||
invalidParams := make([]errs.InvalidParam, 0, len(projectionParams))
|
||||
for _, param := range projectionParams {
|
||||
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s are mutually exclusive", strings.Join(projectionParams, " and ")).
|
||||
WithParam(projectionParams[0]).
|
||||
WithParams(invalidParams...).
|
||||
WithHint("Use only --field-id for projection.")
|
||||
}
|
||||
if fieldsSet {
|
||||
return recordProjectionAliasFields(runtime, "fields", max)
|
||||
}
|
||||
if fieldNamesSet {
|
||||
return recordProjectionAliasFields(runtime, "field-names", max)
|
||||
}
|
||||
fields, err := normalizeRecordSelectFields(fieldIDs, max)
|
||||
return fields, withValidationParam(err, "--field-id")
|
||||
}
|
||||
|
||||
func recordProjectionAliasFields(runtime *common.RuntimeContext, flagName string, max int) ([]string, error) {
|
||||
var fields []string
|
||||
if flagName == "field-names" {
|
||||
fields = runtime.StrSlice(flagName)
|
||||
} else {
|
||||
pc := newParseCtx(runtime)
|
||||
values := runtime.StrArray(flagName)
|
||||
fields = make([]string, 0, len(values))
|
||||
for _, raw := range values {
|
||||
parsed, err := parseStringListFlexible(pc, raw, flagName)
|
||||
if err != nil {
|
||||
return nil, withValidationParam(err, "--"+flagName)
|
||||
}
|
||||
fields = append(fields, parsed...)
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
err := baseFlagErrorf("--%s must include at least one field", flagName)
|
||||
return nil, withValidationParam(err, "--"+flagName)
|
||||
}
|
||||
normalized, err := normalizeRecordSelectFields(fields, max)
|
||||
return normalized, withValidationParam(err, "--"+flagName)
|
||||
return runtime.StrArray("field-id")
|
||||
}
|
||||
|
||||
func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
@@ -524,10 +392,7 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
params := map[string]interface{}{"offset": offset, "limit": limit}
|
||||
fields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := recordListFields(runtime)
|
||||
if len(fields) > 0 {
|
||||
params["field_id"] = fields
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -175,10 +174,7 @@ func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{
|
||||
if len(searchFields) > 0 {
|
||||
body["search_fields"] = searchFields
|
||||
}
|
||||
selectFields, err := recordSearchProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selectFields := recordListFields(runtime)
|
||||
if len(selectFields) > 0 {
|
||||
body["select_fields"] = selectFields
|
||||
}
|
||||
@@ -207,19 +203,6 @@ func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{
|
||||
}
|
||||
|
||||
func normalizeRecordSearchJSONBody(body map[string]interface{}) error {
|
||||
if rawSelectFields, ok := body["select_fields"]; ok {
|
||||
if rawSelectFields == nil {
|
||||
delete(body, "select_fields")
|
||||
} else {
|
||||
selectFields, err := normalizeRecordSearchSelectFields(rawSelectFields)
|
||||
if err != nil {
|
||||
return withValidationParam(err, "--json")
|
||||
}
|
||||
if len(selectFields) > 0 {
|
||||
body["select_fields"] = selectFields
|
||||
}
|
||||
}
|
||||
}
|
||||
if rawSort, ok := body["sort"]; ok {
|
||||
if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil {
|
||||
body["sort"] = sortConfig
|
||||
@@ -236,20 +219,8 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if jsonRaw != "" {
|
||||
if exclusiveParams := recordSearchJSONExclusiveFlagParams(runtime); len(exclusiveParams) > 0 {
|
||||
allParams := append([]string{"--json"}, exclusiveParams...)
|
||||
invalidParams := make([]errs.InvalidParam, 0, len(allParams))
|
||||
for _, param := range allParams {
|
||||
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
|
||||
}
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--json is mutually exclusive with %s",
|
||||
strings.Join(exclusiveParams, " and "),
|
||||
).
|
||||
WithParam("--json").
|
||||
WithParams(invalidParams...).
|
||||
WithHint("Put keyword, search, projection, view, and pagination fields inside --json, or omit --json.")
|
||||
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
|
||||
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
||||
}
|
||||
_, err := recordSearchJSONBody(runtime)
|
||||
return err
|
||||
@@ -271,31 +242,17 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordSearchProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
}
|
||||
|
||||
func recordSearchJSONExclusiveFlagParams(runtime *common.RuntimeContext) []string {
|
||||
names := []string{
|
||||
"keyword",
|
||||
"search-field",
|
||||
"field-id",
|
||||
"fields",
|
||||
"field-names",
|
||||
"view-id",
|
||||
"offset",
|
||||
"limit",
|
||||
"page-size",
|
||||
}
|
||||
params := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
if runtime.Changed(name) {
|
||||
params = append(params, "--"+name)
|
||||
}
|
||||
}
|
||||
return params
|
||||
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
|
||||
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
|
||||
len(runtime.StrArray("search-field")) > 0 ||
|
||||
len(recordListFields(runtime)) > 0 ||
|
||||
runtime.Str("view-id") != "" ||
|
||||
runtime.Changed("offset") ||
|
||||
runtime.Changed("limit") ||
|
||||
runtime.Changed("page-size")
|
||||
}
|
||||
|
||||
func formatRecordQueryPriorityTip() string {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user